diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48e04715c..42b7f4771 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,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" steps: @@ -187,7 +190,7 @@ jobs: - name: "Install PHP with coverage" uses: "shivammathur/setup-php@v2" with: - php-version: "7.4" + php-version: "8.0" ini-values: pcov.directory=. coverage: "pcov" @@ -198,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 @@ -205,9 +211,7 @@ jobs: run: bin/phpunit --color=always -v --debug --coverage-clover=build/logs/clover.xml - 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 + run: vendor/scrutinizer/ocular/bin/ocular code-coverage:upload --format=php-clover build/logs/clover.xml - name: "Upload coverage results to Coveralls" env: 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/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/composer.json b/composer.json index e97b67939..1446d4f7d 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,8 @@ "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", "symfony/dependency-injection": "^4.4 || ^5.0", diff --git a/docs/annotations/annotations-reference.md b/docs/annotations/annotations-reference.md index 8dabff70b..c6eb33faa 100644 --- a/docs/annotations/annotations-reference.md +++ b/docs/annotations/annotations-reference.md @@ -44,6 +44,8 @@ class Coordinates { [@Arg](#arg) +[@ArgsBuilder](#argsBuilder) + [@Deprecated](#deprecated) [@Description](#description) @@ -54,6 +56,8 @@ class Coordinates { [@Field](#field) +[@FieldBuilder](#fieldbuilder) + [@FieldsBuilder](#fieldsbuilder) [@Input](#input) @@ -110,11 +114,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 conjunction 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** : The GraphQL name of the field argument (default to class name). - **type** : The GraphQL type of the field argument Optional attributes: @@ -131,23 +135,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 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: + +- **value** : 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. @@ -204,6 +237,8 @@ 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) + +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 +249,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 +318,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 +344,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 +353,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** : The name of the field builder + +Optional attributes: + +- **config** : The configuration to pass to the field builder + +Example: + +```php +repository->find($id); @@ -481,17 +550,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** : 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 `. + +For example: + +```php +/** + * @GQL\Type + */ +class MyType { + /** + * @GQL\Field + * + * @var Friend[] + */ + public array $friends = []; +} +``` + + ### @Field type auto-guessing when defined on a property with a type hint The type of the `@Field` annotation can be auto-guessed if it's defined on a property with a type hint. 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 4462a204f..4a3391424 100644 --- a/src/Annotation/Access.php +++ b/src/Annotation/Access.php @@ -4,20 +4,25 @@ 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 extends Annotation implements NamedArgumentConstructorAnnotation { /** * Field access. - * - * @Required - * - * @var string */ public string $value; + + public function __construct(string $value) + { + $this->value = $value; + } } diff --git a/src/Annotation/Annotation.php b/src/Annotation/Annotation.php index b788c0a58..64140e7f9 100644 --- a/src/Annotation/Annotation.php +++ b/src/Annotation/Annotation.php @@ -4,6 +4,6 @@ namespace Overblog\GraphQLBundle\Annotation; -interface Annotation +abstract class Annotation { } diff --git a/src/Annotation/Arg.php b/src/Annotation/Arg.php index 970f2c8d0..2ab8c4e3c 100644 --- a/src/Annotation/Arg.php +++ b/src/Annotation/Arg.php @@ -4,36 +4,30 @@ 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 extends Annotation implements NamedArgumentConstructorAnnotation { /** * Argument name. - * - * @Required - * - * @var string */ public string $name; /** * Argument description. - * - * @var string */ - public string $description; + public ?string $description; /** * Argument type. - * - * @Required - * - * @var string */ public string $type; @@ -43,4 +37,18 @@ final class Arg implements Annotation * @var mixed */ public $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) + { + $this->name = $name; + $this->description = $description; + $this->type = $type; + $this->default = $default; + } } diff --git a/src/Annotation/ArgsBuilder.php b/src/Annotation/ArgsBuilder.php new file mode 100644 index 000000000..5e6f3369d --- /dev/null +++ b/src/Annotation/ArgsBuilder.php @@ -0,0 +1,18 @@ +name = $name; + $this->config = $config; + } +} diff --git a/src/Annotation/Deprecated.php b/src/Annotation/Deprecated.php index 4e4efd004..f2ffbf388 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 extends Annotation implements NamedArgumentConstructorAnnotation { /** * 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..be9d72bdb 100644 --- a/src/Annotation/Description.php +++ b/src/Annotation/Description.php @@ -4,20 +4,25 @@ namespace Overblog\GraphQLBundle\Annotation; +use Attribute; +use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; + /** - * Annotation for GraphQL description. + * Annotation for GraphQL to set a type or field description. * * @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 extends Annotation implements NamedArgumentConstructorAnnotation { /** * 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..817d98b99 100644 --- a/src/Annotation/Enum.php +++ b/src/Annotation/Enum.php @@ -4,23 +4,40 @@ 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 extends Annotation implements NamedArgumentConstructorAnnotation { /** * Enum name. - * - * @var string */ - public string $name; + public ?string $name; /** * @var array<\Overblog\GraphQLBundle\Annotation\EnumValue> + * + * @deprecated */ public array $values; + + /** + * @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 = []) + { + $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 0cd08298b..f546fe8b2 100644 --- a/src/Annotation/EnumValue.php +++ b/src/Annotation/EnumValue.php @@ -4,26 +4,40 @@ namespace Overblog\GraphQLBundle\Annotation; +use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; + /** * Annotation for GraphQL enum value. * * @Annotation - * @Target("ANNOTATION") + * @Target({"ANNOTATION", "CLASS"}) */ -final class EnumValue implements Annotation +final class EnumValue extends Annotation implements NamedArgumentConstructorAnnotation { /** * @var string */ - public string $name; + public ?string $name; /** * @var string */ - public string $description; + public ?string $description; /** * @var string */ - public string $deprecationReason; + public ?string $deprecationReason; + + /** + * @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) + { + $this->name = $name; + $this->description = $description; + $this->deprecationReason = $deprecationReason; + } } diff --git a/src/Annotation/Field.php b/src/Annotation/Field.php index 6bee5351c..1e63b7a5d 100644 --- a/src/Annotation/Field.php +++ b/src/Annotation/Field.php @@ -4,46 +4,48 @@ 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 extends Annotation implements NamedArgumentConstructorAnnotation { /** * 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. * * @var mixed + * + * @deprecated */ public $argsBuilder; @@ -51,6 +53,8 @@ class Field implements Annotation * Field builder. * * @var mixed + * + * @deprecated */ public $fieldBuilder; @@ -59,5 +63,44 @@ class Field implements Annotation * * @var string */ - public $complexity; + public ?string $complexity; + + /** + * @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, + 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; + + 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/FieldBuilder.php b/src/Annotation/FieldBuilder.php new file mode 100644 index 000000000..0ba3d151b --- /dev/null +++ b/src/Annotation/FieldBuilder.php @@ -0,0 +1,19 @@ +name = $name; + $this->isRelay = $isRelay; + } } diff --git a/src/Annotation/IsPublic.php b/src/Annotation/IsPublic.php index c841894ce..8663e882c 100644 --- a/src/Annotation/IsPublic.php +++ b/src/Annotation/IsPublic.php @@ -4,20 +4,25 @@ 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 extends Annotation implements NamedArgumentConstructorAnnotation { /** * 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..72cfa0725 100644 --- a/src/Annotation/Mutation.php +++ b/src/Annotation/Mutation.php @@ -4,25 +4,47 @@ namespace Overblog\GraphQLBundle\Annotation; +use Attribute; + /** * Annotation for GraphQL mutation. * * @Annotation * @Target({"METHOD"}) */ +#[Attribute(Attribute::TARGET_METHOD)] final class Mutation extends Field { /** - * @var array + * The target types to attach this mutation to (useful when multiple schemas are allowed). * - * @deprecated This property is deprecated since 1.0 and will be removed in 1.1. Use $targetTypes instead. + * @var array */ - public array $targetType; + public array $targetTypes; /** - * The target types to attach this mutation to (useful when multiple schemas are allowed). + * {@inheritdoc} * - * @var array + * @param string|string[]|null $targetTypes + * @param string|string[]|null $targetType @deprecated */ - 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; + @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 0b04438bf..7ff8bd5fd 100644 --- a/src/Annotation/Provider.php +++ b/src/Annotation/Provider.php @@ -4,32 +4,46 @@ 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 extends Annotation implements NamedArgumentConstructorAnnotation { /** * 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; + + /** + * @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) + { + $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..d6985ca5e 100644 --- a/src/Annotation/Query.php +++ b/src/Annotation/Query.php @@ -4,25 +4,47 @@ namespace Overblog\GraphQLBundle\Annotation; +use Attribute; + /** * Annotation for GraphQL query. * * @Annotation * @Target({"METHOD"}) */ +#[Attribute(Attribute::TARGET_METHOD)] final class Query extends Field { /** - * @var array + * The target types to attach this query to. * - * @deprecated This property is deprecated since 1.0 and will be removed in 1.1. Use $targetTypes instead. + * @var array */ - public array $targetType; + public ?array $targetTypes; /** - * The target types to attach this query to. + * {@inheritdoc} * - * @var array + * @param string|string[]|null $targetTypes + * @param string|string[]|null $targetType */ - 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; + @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 62657c39c..8f79648f4 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,22 @@ * @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..95299f4bd 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,16 @@ * @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..43c29a59f 100644 --- a/src/Annotation/Scalar.php +++ b/src/Annotation/Scalar.php @@ -4,22 +4,29 @@ 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 extends Annotation implements NamedArgumentConstructorAnnotation { - /** - * @var string - */ - public string $name; + 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) + { + $this->name = $name; + $this->scalarType = $scalarType; + } } diff --git a/src/Annotation/Type.php b/src/Annotation/Type.php index 6f7982ccc..3e504267f 100644 --- a/src/Annotation/Type.php +++ b/src/Annotation/Type.php @@ -4,53 +4,79 @@ 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 extends Annotation implements NamedArgumentConstructorAnnotation { /** * 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 */ 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; + + /** + * @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, + array $interfaces = [], + bool $isRelay = false, + string $resolveField = null, + string $isTypeOf = null, + array $builders = [] + ) { + $this->name = $name; + $this->interfaces = $interfaces; + $this->isRelay = $isRelay; + $this->resolveField = $resolveField; + $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 0720a0d60..3cf9b61f9 100644 --- a/src/Annotation/TypeInterface.php +++ b/src/Annotation/TypeInterface.php @@ -4,27 +4,35 @@ 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 extends Annotation implements NamedArgumentConstructorAnnotation { /** * Interface name. - * - * @var string */ - public string $name; + public ?string $name; /** * Resolver type for interface. - * - * @Required - * - * @var string */ public string $resolveType; + + /** + * @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) + { + $this->name = $name; + $this->resolveType = $resolveType; + } } diff --git a/src/Annotation/Union.php b/src/Annotation/Union.php index 7af0ef6f0..500ad747f 100644 --- a/src/Annotation/Union.php +++ b/src/Annotation/Union.php @@ -4,32 +4,42 @@ 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 extends Annotation implements NamedArgumentConstructorAnnotation { /** * 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; + + /** + * @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) + { + $this->name = $name; + $this->types = $types; + $this->resolveType = $resolveType; + } } diff --git a/src/Config/Parser/Annotation/GraphClass.php b/src/Config/Parser/Annotation/GraphClass.php deleted file mode 100644 index fe44e0498..000000000 --- a/src/Config/Parser/Annotation/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 getAnnotations(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; - } -} diff --git a/src/Config/Parser/AnnotationParser.php b/src/Config/Parser/AnnotationParser.php index 8ca6a6901..f988d5411 100644 --- a/src/Config/Parser/AnnotationParser.php +++ b/src/Config/Parser/AnnotationParser.php @@ -4,1065 +4,48 @@ 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)) { + // @codeCoverageIgnoreStart + throw new RuntimeException('In order to use graphql annotation, you need to require doctrine annotations'); + // @codeCoverageIgnoreEnd } - } - - 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..1b0fde019 --- /dev/null +++ b/src/Config/Parser/AttributeParser.php @@ -0,0 +1,34 @@ +getAttributes(); + } + + // @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 new file mode 100644 index 000000000..89562d82c --- /dev/null +++ b/src/Config/Parser/MetadataParser/ClassesTypesMap.php @@ -0,0 +1,79 @@ + + */ + protected array $classesMap = []; + + public function hasType(string $gqlType): bool + { + return isset($this->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 (empty($filteredTypes) || in_array($config['type'], $filteredTypes)) { + return $gqlType; + } + } + } + + return null; + } + + /** + * Resolve the class name associated with given type + */ + public function resolveClass(string $typeName): ?string + { + return $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..3d186e4f7 --- /dev/null +++ b/src/Config/Parser/MetadataParser/MetadataParser.php @@ -0,0 +1,985 @@ +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 DocBlockTypeGuesser(self::$map), + 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 = []; + /** @phpstan-ignore-next-line */ + $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); + } + } + + /** + * @return array + */ + 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 + * @phpstan-param class-string $className + */ + 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; + } + + /** + * @return array{type: 'relay-mutation-payload'|'object', config: array} + */ + 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->name, 'builderConfig' => $fieldsBuilderAnnotation->config]; + }, $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. + * + * @return array{type: 'interface', config: array} + */ + 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. + * + * @return array{type: 'relay-mutation-input'|'input-object', config: array} + */ + 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. + * + * @return array{type: 'custom-scalar', config: array} + */ + 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. + * + * @return array{type: 'enum', config: array} + */ + 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) { + $reflectionConstant = new ReflectionClassConstant($reflectionClass->getName(), $name); + $valueConfig = self::getDescriptionConfiguration(static::getMetadatas($reflectionConstant), true); + + $enumValueAnnotation = current(array_filter($enumValues, fn ($enumValueAnnotation) => $enumValueAnnotation->name === $name)); + $valueConfig['value'] = $value; + + if (false !== $enumValueAnnotation) { + if (isset($enumValueAnnotation->description)) { + $valueConfig['description'] = $enumValueAnnotation->description; + } + + if (isset($enumValueAnnotation->deprecationReason)) { + $valueConfig['deprecationReason'] = $enumValueAnnotation->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. + * + * @return array{type: 'union', config: array} + */ + 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 + * + * @return array + */ + 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 = []; + + /** @var Metadata\Arg[] $argAnnotations */ + $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())); + } + } + } + + $argsBuilder = self::getFirstMetadataMatching($metadatas, Metadata\ArgsBuilder::class); + if ($argsBuilder) { + $fieldConfiguration['argsBuilder'] = ['builder' => $argsBuilder->name, 'config' => $argsBuilder->config]; + } elseif ($fieldMetadata->argsBuilder) { + if (is_string($fieldMetadata->argsBuilder)) { + $fieldConfiguration['argsBuilder'] = ['builder' => $fieldMetadata->argsBuilder, 'config' => []]; + } 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())); + } + } + $fieldBuilder = self::getFirstMetadataMatching($metadatas, Metadata\FieldBuilder::class); + if ($fieldBuilder) { + $fieldConfiguration['builder'] = $fieldBuilder->name; + $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; + $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 + * + * @return array + */ + 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. + * + * @return array + */ + 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 ?? 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. + * + * @return array<'description'|'deprecationReason',string> + */ + 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) { + if (!$typeGuesser->supports($reflector)) { + continue; + } + 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; + } + + /** + * @return ReflectionProperty[] + */ + private static function getClassProperties(ReflectionClass $reflectionClass): array + { + $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/DocBlockTypeGuesser.php b/src/Config/Parser/MetadataParser/TypeGuesser/DocBlockTypeGuesser.php new file mode 100644 index 000000000..21b49f3c4 --- /dev/null +++ b/src/Config/Parser/MetadataParser/TypeGuesser/DocBlockTypeGuesser.php @@ -0,0 +1,138 @@ +createFromReflector($reflectionClass); + $docBlockComment = $reflector->getDocComment(); + if (!$docBlockComment) { + throw new TypeGuessingException(sprintf('Doc Block not found')); + } + + try { + $docBlock = $this->getParser()->create($docBlockComment, $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)); + } + // 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 (2 !== $compound->getIterator()->count() || !$compound->contains($typeNull)) { + return null; + } + foreach ($compound as $type) { + if (!$type instanceof Null_) { + return $type; + } + } + + return null; + } + + 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 new file mode 100644 index 000000000..e757154ed --- /dev/null +++ b/src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php @@ -0,0 +1,176 @@ +doctrineMapping = $doctrineMapping; + } + + 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) { + 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/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/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 ? '' : '!'); + } +} 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/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/Config/Parser/AnnotationParserTest.php b/tests/Config/Parser/AnnotationParserTest.php index 3c391ecbe..088ffa11d 100644 --- a/tests/Config/Parser/AnnotationParserTest.php +++ b/tests/Config/Parser/AnnotationParserTest.php @@ -4,538 +4,63 @@ 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 + public function parser(string $method, ...$args) { - 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)); - } + return AnnotationParser::$method(...$args); } - private function expect(string $name, string $type, array $config = []): void + public function formatMetadata(string $metadata): string { - $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]); + return sprintf('@%s', $metadata); } - public function testExceptionIfRegisterSameType(): void + public function testLegacyNestedAnnotations(): 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', [ + $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!'], - ], - '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))", - ], - ], - ]); - - $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' => [ + 'getList' => [ + 'args' => [ + 'arg1' => ['type' => 'String!'], + 'arg2' => ['type' => 'Int!'], + ], + 'resolve' => '@=call(value.getList, arguments({arg1: "String!", arg2: "Int!"}, args))', '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))", - ], + 'builders' => [ + ['builder' => 'MyFieldsBuilder', 'builderConfig' => ['param1' => 'val1']], ], ]); } - public function testFullqualifiedName(): void - { - $this->assertEquals(self::class, AnnotationParser::fullyQualifiedClassName(self::class, 'Overblog\GraphQLBundle')); - } - - public function testDoctrineGuessing(): void + public function testLegacyFieldsBuilderAttributes(): void { - $this->expect('Lightsaber', 'object', [ + $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!'], - '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']], + ['builder' => 'MyFieldsBuilder', 'builderConfig' => ['param1' => 'val1']], ], ]); } - public function testRelayConnectionEdge(): void + public function testLegacyEnumNestedValue(): void { - $this->expect('FriendsConnection', 'object', [ - 'builders' => [ - ['builder' => 'relay-connection', 'builderConfig' => ['edgeType' => 'FriendsConnectionEdge']], - ], - ]); - - $this->expect('FriendsConnectionEdge', 'object', [ - 'builders' => [ - ['builder' => 'relay-edge', 'builderConfig' => ['nodeType' => 'Character']], + $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'], ], ]); } - - 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..5bf01a19a --- /dev/null +++ b/tests/Config/Parser/AttributeParserTest.php @@ -0,0 +1,23 @@ + '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); + } + } + + 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): void + { + $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()); + } + } + + /** + * @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/MetadataParser/TypeGuesser/DoctrineTypeGuesserTest.php b/tests/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesserTest.php new file mode 100644 index 000000000..86fc1f856 --- /dev/null +++ b/tests/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesserTest.php @@ -0,0 +1,38 @@ +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 new file mode 100644 index 000000000..1638b0c18 --- /dev/null +++ b/tests/Config/Parser/MetadataParserTest.php @@ -0,0 +1,584 @@ + [ + 'schema' => [ + 'default' => ['query' => 'RootQuery', 'mutation' => 'RootMutation'], + 'second' => ['query' => 'RootQuery2', 'mutation' => 'RootMutation2'], + ], + ], + 'doctrine' => [ + 'types_mapping' => [ + 'text[]' => '[String]', + ], + ], + ]; + + /** + * @param array $args + * + * @return mixed + */ + abstract public function parser(string $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']])", + ], + 'notesDeprecated' => [ + 'builder' => 'NoteFieldBuilder', + 'builderConfig' => ['option1' => 'value1'], + ], + 'closestPlanetDeprecated' => [ + '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!'], + 'toys' => ['type' => '[String!]!'], + ], + ]); + } + + 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 testInterfaces(): void + { + $this->expect('WithArmor', 'interface', [ + 'description' => 'The armored interface', + 'resolveType' => '@=resolver(\'character_type\', [value])', + ]); + } + + 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('ResultSearch', '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' => ['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')"], + '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'], + 'text' => ['type' => 'String!'], + 'string' => ['type' => 'String!'], + 'float' => ['type' => 'Float!'], + 'decimal' => ['type' => 'Float!'], + 'bool' => ['type' => 'Boolean!'], + 'boolean' => ['type' => 'Boolean!'], + ], + ]); + } + + 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/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 @@ + "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..90d24b0b7 100644 --- a/tests/Config/Parser/fixtures/annotations/Type/Lightsaber.php +++ b/tests/Config/Parser/fixtures/annotations/Type/Lightsaber.php @@ -11,18 +11,38 @@ * @GQL\Type * @ORM\Entity */ +#[GQL\Type] class Lightsaber { /** * @ORM\Column * @GQL\Field */ - protected string $color; + #[GQL\Field] + // @phpstan-ignore-next-line + protected $color; + + /** + * @ORM\Column(type="text") + * @GQL\Field + */ + #[GQL\Field] + // @phpstan-ignore-next-line + protected $text; + + /** + * @ORM\Column(type="string") + * @GQL\Field + */ + #[GQL\Field] + // @phpstan-ignore-next-line + protected $string; /** * @ORM\Column(type="integer", nullable=true) * @GQL\Field */ + #[GQL\Field] // @phpstan-ignore-next-line protected $size; @@ -30,6 +50,7 @@ class Lightsaber * @ORM\OneToMany(targetEntity="Hero") * @GQL\Field */ + #[GQL\Field] // @phpstan-ignore-next-line protected $holders; @@ -37,6 +58,7 @@ class Lightsaber * @ORM\ManyToOne(targetEntity="Hero") * @GQL\Field */ + #[GQL\Field] // @phpstan-ignore-next-line protected $creator; @@ -44,6 +66,7 @@ class Lightsaber * @ORM\OneToOne(targetEntity="Crystal") * @GQL\Field */ + #[GQL\Field] // @phpstan-ignore-next-line protected $crystal; @@ -51,6 +74,7 @@ class Lightsaber * @ORM\ManyToMany(targetEntity="Battle") * @GQL\Field */ + #[GQL\Field] // @phpstan-ignore-next-line protected $battles; @@ -59,6 +83,7 @@ class Lightsaber * @ORM\OneToOne(targetEntity="Hero") * @ORM\JoinColumn(nullable=true) */ + #[GQL\Field] // @phpstan-ignore-next-line protected $currentHolder; @@ -67,5 +92,39 @@ 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; + + /** + * @ORM\Column(type="float") + * @GQL\Field + */ + #[GQL\Field] + // @phpstan-ignore-next-line + protected $float; + + /** + * @ORM\Column(type="decimal") + * @GQL\Field + */ + #[GQL\Field] + // @phpstan-ignore-next-line + protected $decimal; + + /** + * @ORM\Column(type="bool") + * @GQL\Field + */ + #[GQL\Field] + // @phpstan-ignore-next-line + protected $bool; + + /** + * @ORM\Column(type="boolean") + * @GQL\Field + */ + #[GQL\Field] + // @phpstan-ignore-next-line + protected $boolean; } 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..c0b8c207b 100644 --- a/tests/Config/Parser/fixtures/annotations/Type/Planet.php +++ b/tests/Config/Parser/fixtures/annotations/Type/Planet.php @@ -11,34 +11,60 @@ * @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 + * @GQL\FieldBuilder(name="NoteFieldBuilder", config={"option1"="value1"}) */ + #[GQL\Field] + #[GQL\FieldBuilder("NoteFieldBuilder", ["option1" => "value1"])] public array $notes; /** * @GQL\Field( * type="Planet", - * argsBuilder={"PlanetFilterArgBuilder", {"option2": "value2"}}, * resolve="@=resolver('closest_planet', [args['filter']])" * ) + * @GQL\ArgsBuilder(name="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 $notesDeprecated; + + /** + * @GQL\Field( + * type="Planet", + * argsBuilder={"PlanetFilterArgBuilder", {"option2": "value2"}}, + * resolve="@=resolver('closest_planet', [args['filter']])" + * ) + */ + #[GQL\Field(type: "Planet", argsBuilder: ["PlanetFilterArgBuilder", ["option2" => "value2"]], resolve: "@=resolver('closest_planet', [args['filter']])")] + public Planet $closestPlanetDeprecated; } 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..e02d6e94c 100644 --- a/tests/Config/Parser/fixtures/annotations/Union/SearchResult.php +++ b/tests/Config/Parser/fixtures/annotations/Union/SearchResult.php @@ -7,9 +7,11 @@ use Overblog\GraphQLBundle\Annotation as GQL; /** - * @GQL\Union(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()")] +#[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 diff --git a/tests/Functional/Security/AccessTest.php b/tests/Functional/Security/AccessTest.php index 1dfcbefaf..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'); }