diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9ab2a060..06a02b4b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - php-version: '8.0' symfony-version: '5.3' dependencies: 'lowest' - remove-dependencies: '--dev symfony/validator' + remove-dependencies: '--dev symfony/validator doctrine/orm doctrine/annotations' - php-version: '8.0' symfony-version: '5.3' dependencies: 'lowest' @@ -77,7 +77,6 @@ jobs: run: | composer global require php-coveralls/php-coveralls php-coveralls --coverage_clover=build/logs/clover.xml -v - coding-standard: runs-on: ubuntu-20.04 name: Coding Standard @@ -130,4 +129,4 @@ jobs: uses: ramsey/composer-install@1.3.0 - name: "Run static-analysis" - run: composer static-analysis + run: composer static-analysis \ No newline at end of file diff --git a/README.md b/README.md index 548a7dd99..d140f1b41 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Talks and slides to help you start * GraphQL in Symfony *by Bernd Alter* - [Twitter](https://twitter.com/bazoo0815) - [Talk about GraphQL and its implementation with Symfony (26.04.2017)](https://www.slideshare.net/berndalter7/graphql-in-symfony) `English` -* GraphQL is right in front of us, let's to it! *by Renato Mendes Figueiredo* - [Twitter](https://twitter.com/renatomefi), [GitHub](https://github.com/renatomefi) +* GraphQL is right in front of us, let's do it! *by Renato Mendes Figueiredo* - [Twitter](https://twitter.com/renatomefi), [GitHub](https://github.com/renatomefi) - [Slides at http://talks.mefi.in/graphql-scotphp17](http://talks.mefi.in/graphql-scotphp17/) `English` - [Video at SymfonyCamp UA 2017](https://www.youtube.com/watch?v=jyoYlnCPNgk) `English` - [Video at DPC 2017](https://www.youtube.com/watch?v=E7MjoCOGSSY) `English` diff --git a/composer.json b/composer.json index 2bf054683..b7a3d050b 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "type": "symfony-bundle", "license": "MIT", "description": "This bundle provides tools to build a GraphQL server in your Symfony App.", - "keywords": ["GraphQL", "Relay"], + "keywords": ["GraphQL","Relay"], "authors": [ { "name": "Overblog", @@ -83,7 +83,7 @@ "static-analysis": [ "phpstan analyse --ansi --memory-limit=1G" ], - "bench": [ + "bench": [ "test -f phpbench.phar || wget https://github.com/phpbench/phpbench/releases/download/1.0.0-alpha7/phpbench.phar -O phpbench.phar", "@php phpbench.phar run -l dots --ansi -vvv --report='generator: \"table\", cols: [\"benchmark\", \"subject\", \"params\", \"best\", \"mean\", \"mode\", \"worst\", \"diff\"], break: [\"benchmark\"], sort: {mean: \"asc\"}'" ], diff --git a/docs/annotations/index.md b/docs/annotations/index.md index 44cd83e27..c86e859a5 100644 --- a/docs/annotations/index.md +++ b/docs/annotations/index.md @@ -2,7 +2,9 @@ In order to use annotations or attributes, you need to configure the mapping: -To use annotations, use the `annotation` mapping type. +To use annotations, You must install `symfony/cache` and `doctrine/annotation` and use the `annotation` mapping type. + + ```yaml # config/packages/graphql.yaml overblog_graphql: @@ -13,7 +15,6 @@ overblog_graphql: dir: "%kernel.project_dir%/src/GraphQL" suffix: ~ ``` - To use attributes, use the `attribute` mapping type. ```yaml @@ -215,6 +216,7 @@ In this example, the type `String!` will be auto-guessed from the type hint of t ### @Field type auto-guessing from Doctrine ORM Annotations Based on other Doctrine annotations on your fields, the corresponding GraphQL type can sometimes be guessed automatically. +In order to activate this guesser, you must install `doctrine/orm` package. The type can be auto-guessed from the following annotations: diff --git a/docs/error-handling/index.md b/docs/error-handling/index.md index 7de60a3b1..be3f2ca1c 100644 --- a/docs/error-handling/index.md +++ b/docs/error-handling/index.md @@ -159,7 +159,7 @@ Custom error handling / formatting ----------------------------------- This can also be done by using events. -* First totally disabled default errors handler: +* First totally disable default errors handler: ```yaml overblog_graphql: errors_handler: false diff --git a/docs/index.md b/docs/index.md index 0315b61b4..ebe064dcf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -102,6 +102,15 @@ overblog_graphql_multiple_endpoint: prefix: /graphql ``` + +Optionnal features depencies +------------ + +- To use the Validator features, you must also install `symfony/validator` and `doctrine/annotations` +- To use the annotations, you must also install `doctrine/annotations` +- To use the annotations doctrine type guesser, you must also install `doctrine/orm` + + Composer autoloader configuration (optional) ------------ diff --git a/src/Config/Parser/AnnotationParser.php b/src/Config/Parser/AnnotationParser.php index d262a8d49..5ef806f05 100644 --- a/src/Config/Parser/AnnotationParser.php +++ b/src/Config/Parser/AnnotationParser.php @@ -6,18 +6,22 @@ use Doctrine\Common\Annotations\AnnotationReader; use Doctrine\Common\Annotations\AnnotationRegistry; +use Doctrine\Common\Annotations\PsrCachedReader; +use Doctrine\Common\Annotations\Reader; use Overblog\GraphQLBundle\Config\Parser\MetadataParser\MetadataParser; use ReflectionClass; use ReflectionMethod; use ReflectionProperty; use Reflector; -use RuntimeException; +use Symfony\Component\Cache\Adapter\ApcuAdapter; +use Symfony\Component\Cache\Adapter\PhpFilesAdapter; +use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; class AnnotationParser extends MetadataParser { public const METADATA_FORMAT = '@%s'; - protected static ?AnnotationReader $annotationReader = null; + protected static Reader $annotationReader; protected static function getMetadatas(Reflector $reflector): array { @@ -32,18 +36,29 @@ protected static function getMetadatas(Reflector $reflector): array return []; } - protected static function getAnnotationReader(): AnnotationReader + public static function getAnnotationReader(): Reader { - 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 + if (!isset(self::$annotationReader)) { + if (!class_exists(AnnotationReader::class)) { + throw new ServiceNotFoundException("In order to use annotations, you need to install 'doctrine/annotations' first. See: 'https://www.doctrine-project.org/projects/annotations.html'"); } + if (!class_exists(ApcuAdapter::class)) { + throw new ServiceNotFoundException("In order to use annotations, you need to install 'symfony/cache' first. See: 'https://symfony.com/doc/current/components/cache.html'"); + } + + if (class_exists(AnnotationRegistry::class)) { + AnnotationRegistry::registerLoader('class_exists'); + } + $cacheKey = md5(__DIR__); + // @codeCoverageIgnoreStart + if (extension_loaded('apcu') && apcu_enabled()) { + $annotationCache = new ApcuAdapter($cacheKey); + } else { + $annotationCache = new PhpFilesAdapter($cacheKey); + } + // @codeCoverageIgnoreEnd - AnnotationRegistry::registerLoader('class_exists'); - self::$annotationReader = new AnnotationReader(); + self::$annotationReader = new PsrCachedReader(new AnnotationReader(), $annotationCache, true); } return self::$annotationReader; diff --git a/src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php b/src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php index 6715ab5f0..79053cacd 100644 --- a/src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php +++ b/src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php @@ -4,8 +4,6 @@ namespace Overblog\GraphQLBundle\Config\Parser\MetadataParser\TypeGuesser; -use Doctrine\Common\Annotations\AnnotationReader; -use Doctrine\Common\Annotations\AnnotationRegistry; use Doctrine\ORM\Mapping\Annotation as MappingAnnotation; use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\JoinColumn; @@ -13,16 +11,15 @@ use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\OneToMany; use Doctrine\ORM\Mapping\OneToOne; +use Overblog\GraphQLBundle\Config\Parser\AnnotationParser; use Overblog\GraphQLBundle\Config\Parser\MetadataParser\ClassesTypesMap; use ReflectionClass; use ReflectionMethod; use ReflectionProperty; use Reflector; -use RuntimeException; class DoctrineTypeGuesser extends TypeGuesser { - protected ?AnnotationReader $annotationReader = null; protected array $doctrineMapping = []; public function __construct(ClassesTypesMap $map, array $doctrineMapping = []) @@ -46,14 +43,19 @@ public function supports(Reflector $reflector): bool */ public function guessType(ReflectionClass $reflectionClass, Reflector $reflector, array $filterGraphQLTypes = []): ?string { + if (!class_exists(Column::class)) { + throw new TypeGuessingException(sprintf('You must install doctrine/orm package to use this type guesser.')); + } + 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 ?? 'string'); + $type = $this->resolveTypeFromDoctrineType($columnAnnotation->type ?: 'string'); $nullable = $columnAnnotation->nullable; if ($type) { return $nullable ? $type : sprintf('%s!', $type); @@ -100,7 +102,7 @@ public function guessType(ReflectionClass $reflectionClass, Reflector $reflector private function getAnnotation(Reflector $reflector, string $annotationClass): ?MappingAnnotation { - $reader = $this->getAnnotationReader(); + $reader = AnnotationParser::getAnnotationReader(); $annotations = []; switch (true) { case $reflector instanceof ReflectionClass: $annotations = $reader->getClassAnnotations($reflector); break; @@ -117,21 +119,6 @@ private function getAnnotation(Reflector $reflector, string $annotationClass): ? 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. * diff --git a/tests/Config/Parser/AnnotationParserTest.php b/tests/Config/Parser/AnnotationParserTest.php index 088ffa11d..d09259dd1 100644 --- a/tests/Config/Parser/AnnotationParserTest.php +++ b/tests/Config/Parser/AnnotationParserTest.php @@ -6,9 +6,22 @@ use Overblog\GraphQLBundle\Config\Parser\AnnotationParser; use SplFileInfo; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; +use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; class AnnotationParserTest extends MetadataParserTest { + public function setUp(): void + { + if ('testNoDoctrineAnnotations' !== $this->getName()) { + if (!self::isDoctrineAnnotationInstalled()) { + $this->markTestSkipped('doctrine/annotations are not installed. Skipping annotation parser tests.'); + } + parent::setUp(); + } + } + public function parser(string $method, ...$args) { return AnnotationParser::$method(...$args); @@ -19,6 +32,21 @@ public function formatMetadata(string $metadata): string return sprintf('@%s', $metadata); } + public function testNoDoctrineAnnotations(): void + { + if (self::isDoctrineAnnotationInstalled()) { + $this->markTestSkipped('doctrine/annotations are installed'); + } + + try { + $containerBuilder = $this->getMockBuilder(ContainerBuilder::class)->disableOriginalConstructor()->getMock(); + AnnotationParser::parse(new SplFileInfo(__DIR__.'/fixtures/annotations/Type/Animal.php'), $containerBuilder); + } catch (InvalidArgumentException $e) { + $this->assertInstanceOf(ServiceNotFoundException::class, $e->getPrevious()); + $this->assertMatchesRegularExpression('/doctrine\/annotations/', $e->getPrevious()->getMessage()); + } + } + public function testLegacyNestedAnnotations(): void { $this->config = self::cleanConfig($this->parser('parse', new SplFileInfo(__DIR__.'/fixtures/annotations/Deprecated/DeprecatedNestedAnnotations.php'), $this->containerBuilder, ['doctrine' => ['types_mapping' => []]])); diff --git a/tests/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesserTest.php b/tests/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesserTest.php index 86fc1f856..5a892c87b 100644 --- a/tests/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesserTest.php +++ b/tests/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesserTest.php @@ -4,6 +4,7 @@ namespace Overblog\GraphQLBundle\Tests\Config\Parser; +use Doctrine\ORM\Mapping\Column; use Exception; use Overblog\GraphQLBundle\Config\Parser\MetadataParser\ClassesTypesMap; use Overblog\GraphQLBundle\Config\Parser\MetadataParser\TypeGuesser\DoctrineTypeGuesser; @@ -15,24 +16,47 @@ class DoctrineTypeGuesserTest extends TestCase // @phpstan-ignore-next-line protected $property; + public static function isDoctrineInstalled(): bool + { + return class_exists(Column::class); + } + public function testGuessError(): void { + if (!self::isDoctrineInstalled()) { + $this->markTestSkipped('Doctrine ORM is not installed'); + } + $refClass = new ReflectionClass(__CLASS__); - $docBlockGuesser = new DoctrineTypeGuesser(new ClassesTypesMap()); + $doctrineGuesser = new DoctrineTypeGuesser(new ClassesTypesMap()); try { // @phpstan-ignore-next-line - $docBlockGuesser->guessType($refClass, $refClass); + $doctrineGuesser->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')); + $doctrineGuesser->guessType($refClass, $refClass->getProperty('property')); } catch (Exception $e) { $this->assertInstanceOf(TypeGuessingException::class, $e); $this->assertStringContainsString('No Doctrine ORM annotation found.', $e->getMessage()); } } + + public function testDoctrineOrmNotInstalled(): void + { + if (self::isDoctrineInstalled()) { + $this->markTestSkipped('Doctrine ORM is installed'); + } + + $this->expectException(TypeGuessingException::class); + $this->expectExceptionMessageMatches('/^You must install doctrine\/orm/'); + + $refClass = new ReflectionClass(__CLASS__); + $doctrineGuesser = new DoctrineTypeGuesser(new ClassesTypesMap()); + $doctrineGuesser->guessType($refClass, $refClass->getProperty('property')); + } } diff --git a/tests/Config/Parser/MetadataParserTest.php b/tests/Config/Parser/MetadataParserTest.php index 1638b0c18..ed8dd8b47 100644 --- a/tests/Config/Parser/MetadataParserTest.php +++ b/tests/Config/Parser/MetadataParserTest.php @@ -4,6 +4,8 @@ namespace Overblog\GraphQLBundle\Tests\Config\Parser; +use Doctrine\Common\Annotations\Reader; +use Doctrine\ORM\Mapping\Column; use Exception; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; @@ -45,6 +47,10 @@ public function setUp(): void { parent::setup(); + if (!self::isDoctrineAnnotationInstalled()) { + $this->markTestSkipped('doctrine/annotations are not installed'); + } + $files = []; $rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(__DIR__.'/fixtures/annotations/')); foreach ($rii as $file) { @@ -53,6 +59,9 @@ public function setUp(): void if (false !== strpos($file->getPathName(), $ignoredPath)) { continue 2; } + if (!self::isDoctrineOrmInstalled() && 'Lightsaber.php' === $file->getFileName()) { + continue 2; + } } $files[] = $file->getPathname(); @@ -70,6 +79,16 @@ public function setUp(): void } } + public static function isDoctrineAnnotationInstalled(): bool + { + return interface_exists(Reader::class); + } + + public static function isDoctrineOrmInstalled(): bool + { + return class_exists(Column::class); + } + protected function expect(string $name, string $type, array $config = []): void { $expected = [ @@ -395,6 +414,10 @@ public function testProvidersMultischema(): void public function testDoctrineGuessing(): void { + if (!self::isDoctrineOrmInstalled()) { + $this->markTestSkipped('doctrine/orm is not installed'); + } + $this->expect('Lightsaber', 'object', [ 'fields' => [ 'color' => ['type' => 'String!'], @@ -495,6 +518,9 @@ public function testInvalidReturnGuessing(): void public function testInvalidDoctrineRelationGuessing(): void { + if (!self::isDoctrineOrmInstalled()) { + $this->markTestSkipped('doctrine/orm is not installed'); + } try { $file = __DIR__.'/fixtures/annotations/Invalid/InvalidDoctrineRelationGuessing.php'; $this->parser('parse', new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); @@ -507,6 +533,9 @@ public function testInvalidDoctrineRelationGuessing(): void public function testInvalidDoctrineTypeGuessing(): void { + if (!self::isDoctrineOrmInstalled()) { + $this->markTestSkipped('doctrine/orm is not installed'); + } try { $file = __DIR__.'/fixtures/annotations/Invalid/InvalidDoctrineTypeGuessing.php'; $this->parser('parse', new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); diff --git a/tests/Config/Parser/fixtures/annotations/Input/Planet.php b/tests/Config/Parser/fixtures/annotations/Input/Planet.php index c63296c9d..e4a7507c6 100644 --- a/tests/Config/Parser/fixtures/annotations/Input/Planet.php +++ b/tests/Config/Parser/fixtures/annotations/Input/Planet.php @@ -4,7 +4,6 @@ namespace Overblog\GraphQLBundle\Tests\Config\Parser\fixtures\annotations\Input; -use Doctrine\ORM\Mapping as ORM; use Overblog\GraphQLBundle\Annotation as GQL; /** @@ -41,15 +40,13 @@ class Planet /** * @GQL\Field - * @ORM\Column(type="integer", nullable=true) */ #[GQL\Field] // @phpstan-ignore-next-line - protected $diameter; + protected ?int $diameter; /** * @GQL\Field - * @ORM\Column(type="boolean") */ #[GQL\Field] protected int $variable; @@ -58,9 +55,8 @@ class Planet protected $dummy; /** - * @GQL\Field - * @ORM\Column(type="text[]") + * @GQL\Field(type="[String]!") */ - #[GQL\Field] + #[GQL\Field(type: '[String]!')] protected array $tags; } diff --git a/tests/DependencyInjection/Compiler/ConfigParserPassTest.php b/tests/DependencyInjection/Compiler/ConfigParserPassTest.php index 097c82bce..f0f6ffa32 100644 --- a/tests/DependencyInjection/Compiler/ConfigParserPassTest.php +++ b/tests/DependencyInjection/Compiler/ConfigParserPassTest.php @@ -10,6 +10,7 @@ use Overblog\GraphQLBundle\DependencyInjection\OverblogGraphQLExtension; use Overblog\GraphQLBundle\Error\ExceptionConverter; use Overblog\GraphQLBundle\Error\UserWarning; +use Overblog\GraphQLBundle\Tests\Config\Parser\MetadataParserTest; use Overblog\GraphQLBundle\Tests\DependencyInjection\Builder\BoxFields; use Overblog\GraphQLBundle\Tests\DependencyInjection\Builder\MutationField; use Overblog\GraphQLBundle\Tests\DependencyInjection\Builder\PagerArgs; @@ -51,6 +52,9 @@ public function testBrokenYmlOnPrepend(): void public function testPreparseOnPrepend(): void { + if (!MetadataParserTest::isDoctrineAnnotationInstalled()) { + $this->markTestSkipped('doctrine/annotations not installed. Skipping test.'); + } $this->expectException(InvalidConfigurationException::class); $this->expectExceptionMessage('The path "overblog_graphql_types.Type._object_config.fields" should have at least 1 element(s) defined.'); $this->processCompilerPass($this->getMappingConfig('annotation')); diff --git a/tests/Functional/Validator/InputValidatorTest.php b/tests/Functional/Validator/InputValidatorTest.php index 1897ab2db..103b911ae 100644 --- a/tests/Functional/Validator/InputValidatorTest.php +++ b/tests/Functional/Validator/InputValidatorTest.php @@ -4,6 +4,7 @@ namespace Overblog\GraphQLBundle\Tests\Functional\Validator; +use Doctrine\Common\Annotations\Reader; use Overblog\GraphQLBundle\Tests\Functional\TestCase; use Symfony\Component\Validator\Validation; use function class_exists; @@ -17,6 +18,9 @@ protected function setUp(): void if (!class_exists(Validation::class)) { $this->markTestSkipped('Symfony validator component is not installed'); } + if (!interface_exists(Reader::class)) { + $this->markTestSkipped('Symfony validator component requires doctrine/annotations but it is not installed'); + } static::bootKernel(['test_case' => 'validator']); }