From 1e64ea7ed1d94e3d38374934e35145366a6805fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 Mar 2023 19:43:12 -0500 Subject: [PATCH 001/108] Update beberlei/porpaginas requirement from ^1.2 to ^1.2 || ^2.0 (#558) Updates the requirements on [beberlei/porpaginas](https://github.com/beberlei/porpaginas) to permit the latest version. - [Release notes](https://github.com/beberlei/porpaginas/releases) - [Commits](https://github.com/beberlei/porpaginas/compare/v1.2...v2.0) --- updated-dependencies: - dependency-name: beberlei/porpaginas dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index e501cf35dd..9444c78996 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ "webonyx/graphql-php": "^v15.0" }, "require-dev": { - "beberlei/porpaginas": "^1.2", + "beberlei/porpaginas": "^1.2 || ^2.0", "doctrine/coding-standard": "^11.0", "ecodev/graphql-upload": "^7.0", "laminas/laminas-diactoros": "^2", From db4d6049121bd58f9b5505aaed84e87e132d554f Mon Sep 17 00:00:00 2001 From: oprypkhantc <54406427+oprypkhantc@users.noreply.github.com> Date: Sun, 12 Mar 2023 08:18:53 +0200 Subject: [PATCH 002/108] Fix authorization middleware on long running servers (#571) --- src/InputField.php | 20 --- .../AuthorizationFieldMiddleware.php | 46 ++++--- .../AuthorizationInputFieldMiddleware.php | 22 +++- src/QueryField.php | 21 +-- .../AuthorizationFieldMiddlewareTest.php | 109 +++++++++++++--- .../AuthorizationInputFieldMiddlewareTest.php | 122 ++++++++++++++++++ website/docs/annotations-reference.md | 4 + website/docs/authentication-authorization.mdx | 4 +- website/docs/field-middlewares.md | 4 +- 9 files changed, 274 insertions(+), 78 deletions(-) create mode 100644 tests/Middlewares/AuthorizationInputFieldMiddlewareTest.php diff --git a/src/InputField.php b/src/InputField.php index 367ff96d55..24555df7ec 100644 --- a/src/InputField.php +++ b/src/InputField.php @@ -13,7 +13,6 @@ use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; use TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException; -use TheCodingMachine\GraphQLite\Middlewares\MissingAuthorizationException; use TheCodingMachine\GraphQLite\Middlewares\ResolverInterface; use TheCodingMachine\GraphQLite\Middlewares\SourceResolverInterface; use TheCodingMachine\GraphQLite\Parameters\MissingArgumentException; @@ -123,25 +122,6 @@ public function forConstructorHydration(): bool return $this->forConstructorHydration; } - /** - * @param bool $isNotLogged False if the user is logged (and the error is a 403), true if the error is unlogged (the error is a 401) - * - * @return InputField - */ - public static function unauthorizedError(InputFieldDescriptor $fieldDescriptor, bool $isNotLogged): self - { - $callable = static function () use ($isNotLogged): void { - if ($isNotLogged) { - throw MissingAuthorizationException::unauthorized(); - } - throw MissingAuthorizationException::forbidden(); - }; - - $fieldDescriptor->setResolver($callable); - - return self::fromDescriptor($fieldDescriptor); - } - private static function fromDescriptor(InputFieldDescriptor $fieldDescriptor): self { return new self( diff --git a/src/Middlewares/AuthorizationFieldMiddleware.php b/src/Middlewares/AuthorizationFieldMiddleware.php index 13a212658a..59c6a25422 100644 --- a/src/Middlewares/AuthorizationFieldMiddleware.php +++ b/src/Middlewares/AuthorizationFieldMiddleware.php @@ -12,7 +12,6 @@ use TheCodingMachine\GraphQLite\Annotations\HideIfUnauthorized; use TheCodingMachine\GraphQLite\Annotations\Logged; use TheCodingMachine\GraphQLite\Annotations\Right; -use TheCodingMachine\GraphQLite\QueryField; use TheCodingMachine\GraphQLite\QueryFieldDescriptor; use TheCodingMachine\GraphQLite\Security\AuthenticationServiceInterface; use TheCodingMachine\GraphQLite\Security\AuthorizationServiceInterface; @@ -39,8 +38,19 @@ public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandler $rightAnnotation = $annotations->getAnnotationByType(Right::class); assert($rightAnnotation === null || $rightAnnotation instanceof Right); + // Avoid wrapping resolver callback when no annotations are specified. + if (! $loggedAnnotation && ! $rightAnnotation) { + return $fieldHandler->handle($queryFieldDescriptor); + } + $failWith = $annotations->getAnnotationByType(FailWith::class); assert($failWith === null || $failWith instanceof FailWith); + $hideIfUnauthorized = $annotations->getAnnotationByType(HideIfUnauthorized::class); + assert($hideIfUnauthorized instanceof HideIfUnauthorized || $hideIfUnauthorized === null); + + if ($failWith !== null && $hideIfUnauthorized !== null) { + throw IncompatibleAnnotationsException::cannotUseFailWithAndHide(); + } // If the failWith value is null and the return type is non-nullable, we must set it to nullable. $type = $queryFieldDescriptor->getType(); @@ -50,28 +60,32 @@ public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandler $queryFieldDescriptor->setType($type); } - if ($this->isAuthorized($loggedAnnotation, $rightAnnotation)) { - return $fieldHandler->handle($queryFieldDescriptor); + // When using the same Schema instance for multiple subsequent requests, this middleware will only + // get called once, meaning #[HideIfUnauthorized] only works when Schema is used for a single request + // and then discarded. This check is to keep the latter case working. + if ($hideIfUnauthorized !== null && ! $this->isAuthorized($loggedAnnotation, $rightAnnotation)) { + return null; } - $hideIfUnauthorized = $annotations->getAnnotationByType(HideIfUnauthorized::class); - assert($hideIfUnauthorized instanceof HideIfUnauthorized || $hideIfUnauthorized === null); + $resolver = $queryFieldDescriptor->getResolver(); - if ($failWith !== null && $hideIfUnauthorized !== null) { - throw IncompatibleAnnotationsException::cannotUseFailWithAndHide(); - } + $queryFieldDescriptor->setResolver(function (...$args) use ($rightAnnotation, $loggedAnnotation, $failWith, $resolver) { + if ($this->isAuthorized($loggedAnnotation, $rightAnnotation)) { + return $resolver(...$args); + } - if ($failWith !== null) { - $failWithValue = $failWith->getValue(); + if ($failWith !== null) { + return $failWith->getValue(); + } - return QueryField::alwaysReturn($queryFieldDescriptor, $failWithValue); - } + if ($loggedAnnotation !== null && ! $this->authenticationService->isLogged()) { + throw MissingAuthorizationException::unauthorized(); + } - if ($hideIfUnauthorized !== null) { - return null; - } + throw MissingAuthorizationException::forbidden(); + }); - return QueryField::unauthorizedError($queryFieldDescriptor, $loggedAnnotation !== null && ! $this->authenticationService->isLogged()); + return $fieldHandler->handle($queryFieldDescriptor); } /** diff --git a/src/Middlewares/AuthorizationInputFieldMiddleware.php b/src/Middlewares/AuthorizationInputFieldMiddleware.php index c31e6c276b..2151ccfda6 100644 --- a/src/Middlewares/AuthorizationInputFieldMiddleware.php +++ b/src/Middlewares/AuthorizationInputFieldMiddleware.php @@ -36,17 +36,33 @@ public function process(InputFieldDescriptor $inputFieldDescriptor, InputFieldHa $rightAnnotation = $annotations->getAnnotationByType(Right::class); assert($rightAnnotation === null || $rightAnnotation instanceof Right); - if ($this->isAuthorized($loggedAnnotation, $rightAnnotation)) { + // Avoid wrapping resolver callback when no annotations are specified. + if (! $loggedAnnotation && ! $rightAnnotation) { return $inputFieldHandler->handle($inputFieldDescriptor); } $hideIfUnauthorized = $annotations->getAnnotationByType(HideIfUnauthorized::class); assert($hideIfUnauthorized instanceof HideIfUnauthorized || $hideIfUnauthorized === null); - if ($hideIfUnauthorized !== null) { + if ($hideIfUnauthorized !== null && ! $this->isAuthorized($loggedAnnotation, $rightAnnotation)) { return null; } - return InputField::unauthorizedError($inputFieldDescriptor, $loggedAnnotation !== null && ! $this->authenticationService->isLogged()); + + $resolver = $inputFieldDescriptor->getResolver(); + + $inputFieldDescriptor->setResolver(function (...$args) use ($rightAnnotation, $loggedAnnotation, $resolver) { + if ($this->isAuthorized($loggedAnnotation, $rightAnnotation)) { + return $resolver(...$args); + } + + if ($loggedAnnotation !== null && ! $this->authenticationService->isLogged()) { + throw MissingAuthorizationException::unauthorized(); + } + + throw MissingAuthorizationException::forbidden(); + }); + + return $inputFieldHandler->handle($inputFieldDescriptor); } /** diff --git a/src/QueryField.php b/src/QueryField.php index df02c837db..e4be4cb841 100644 --- a/src/QueryField.php +++ b/src/QueryField.php @@ -15,7 +15,6 @@ use GraphQL\Type\Definition\Type; use TheCodingMachine\GraphQLite\Context\ContextInterface; use TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException; -use TheCodingMachine\GraphQLite\Middlewares\MissingAuthorizationException; use TheCodingMachine\GraphQLite\Middlewares\ResolverInterface; use TheCodingMachine\GraphQLite\Middlewares\SourceResolverInterface; use TheCodingMachine\GraphQLite\Parameters\MissingArgumentException; @@ -143,6 +142,7 @@ public function __construct(string $name, OutputType $type, array $arguments, Re } $config += $additionalConfig; + parent::__construct($config); } @@ -186,25 +186,6 @@ public static function alwaysReturn(QueryFieldDescriptor $fieldDescriptor, mixed return self::fromDescriptor($fieldDescriptor); } - /** - * @param bool $isNotLogged False if the user is logged (and the error is a 403), true if the error is unlogged (the error is a 401) - * - * @return QueryField - */ - public static function unauthorizedError(QueryFieldDescriptor $fieldDescriptor, bool $isNotLogged): self - { - $callable = static function () use ($isNotLogged): void { - if ($isNotLogged) { - throw MissingAuthorizationException::unauthorized(); - } - throw MissingAuthorizationException::forbidden(); - }; - - $fieldDescriptor->setResolver($callable); - - return self::fromDescriptor($fieldDescriptor); - } - private static function fromDescriptor(QueryFieldDescriptor $fieldDescriptor): self { $type = $fieldDescriptor->getType(); diff --git a/tests/Middlewares/AuthorizationFieldMiddlewareTest.php b/tests/Middlewares/AuthorizationFieldMiddlewareTest.php index 3fcde0f3b1..577da826ec 100644 --- a/tests/Middlewares/AuthorizationFieldMiddlewareTest.php +++ b/tests/Middlewares/AuthorizationFieldMiddlewareTest.php @@ -3,43 +3,120 @@ namespace TheCodingMachine\GraphQLite\Middlewares; use GraphQL\Type\Definition\FieldDefinition; -use PHPUnit\Framework\TestCase; -use ReflectionMethod; use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; use TheCodingMachine\GraphQLite\Annotations\Exceptions\IncompatibleAnnotationsException; use TheCodingMachine\GraphQLite\Annotations\FailWith; use TheCodingMachine\GraphQLite\Annotations\HideIfUnauthorized; use TheCodingMachine\GraphQLite\Annotations\Logged; +use TheCodingMachine\GraphQLite\Annotations\MiddlewareAnnotationInterface; +use TheCodingMachine\GraphQLite\Annotations\MiddlewareAnnotations; +use TheCodingMachine\GraphQLite\Annotations\Right; use TheCodingMachine\GraphQLite\QueryFieldDescriptor; +use TheCodingMachine\GraphQLite\Security\AuthenticationServiceInterface; +use TheCodingMachine\GraphQLite\Security\AuthorizationServiceInterface; use TheCodingMachine\GraphQLite\Security\VoidAuthenticationService; use TheCodingMachine\GraphQLite\Security\VoidAuthorizationService; class AuthorizationFieldMiddlewareTest extends AbstractQueryProviderTest { + public function testReturnsResolversValueWhenAuthorized(): void + { + $authenticationService = $this->createMock(AuthenticationServiceInterface::class); + $authenticationService->method('isLogged') + ->willReturn(true); + $authorizationService = $this->createMock(AuthorizationServiceInterface::class); + $authorizationService->method('isAllowed') + ->willReturn(true); + $middleware = new AuthorizationFieldMiddleware($authenticationService, $authorizationService); + + $descriptor = $this->stubDescriptor([new Logged(), new Right('test')]); + $descriptor->setResolver(fn () => 123); + + $field = $middleware->process($descriptor, $this->stubFieldHandler()); + + self::assertNotNull($field); + self::assertSame(123, ($field->resolveFn)()); + } + - public function testException(): void + public function testFailsForHideIfUnauthorizedAndFailWith(): void { $middleware = new AuthorizationFieldMiddleware(new VoidAuthenticationService(), new VoidAuthorizationService()); - $descriptor = new QueryFieldDescriptor(); - $descriptor->setMiddlewareAnnotations($this->getAnnotationReader()->getMiddlewareAnnotations(new ReflectionMethod(__CLASS__, 'stub'))); - $this->expectException(IncompatibleAnnotationsException::class); - $middleware->process($descriptor, new class implements FieldHandlerInterface { - public function handle(QueryFieldDescriptor $fieldDescriptor): ?FieldDefinition - { - return FieldDefinition::create(['name'=>'foo']); - } - }); + $middleware->process($this->stubDescriptor([new Logged(), new HideIfUnauthorized(), new FailWith(value: 123)]), $this->stubFieldHandler()); + } + + public function testHidesFieldForHideIfUnauthorized(): void + { + $middleware = new AuthorizationFieldMiddleware(new VoidAuthenticationService(), new VoidAuthorizationService()); + + $field = $middleware->process($this->stubDescriptor([new Logged(), new HideIfUnauthorized()]), $this->stubFieldHandler()); + + self::assertNull($field); + } + + public function testReturnsFailsWithValueWhenNotAuthorized(): void + { + $middleware = new AuthorizationFieldMiddleware(new VoidAuthenticationService(), new VoidAuthorizationService()); + + $field = $middleware->process($this->stubDescriptor([new Logged(), new FailWith(value: 123)]), $this->stubFieldHandler()); + + self::assertNotNull($field); + self::assertSame(123, ($field->resolveFn)()); + } + + public function testThrowsUnauthorizedExceptionWhenNotAuthorized(): void + { + $middleware = new AuthorizationFieldMiddleware(new VoidAuthenticationService(), new VoidAuthorizationService()); + + $field = $middleware->process($this->stubDescriptor([new Logged()]), $this->stubFieldHandler()); + + self::assertNotNull($field); + + $this->expectExceptionObject(MissingAuthorizationException::unauthorized()); + + ($field->resolveFn)(); + } + + public function testThrowsForbiddenExceptionWhenNotAuthorized(): void + { + $authenticationService = $this->createMock(AuthenticationServiceInterface::class); + $authenticationService->method('isLogged') + ->willReturn(true); + $middleware = new AuthorizationFieldMiddleware($authenticationService, new VoidAuthorizationService()); + + $field = $middleware->process($this->stubDescriptor([new Logged(), new Right('test')]), $this->stubFieldHandler()); + + self::assertNotNull($field); + + $this->expectExceptionObject(MissingAuthorizationException::forbidden()); + + ($field->resolveFn)(); } /** - * @Logged() - * @HideIfUnauthorized() - * @FailWith(null) + * @param MiddlewareAnnotationInterface[] $annotations */ - public function stub() + private function stubDescriptor(array $annotations): QueryFieldDescriptor { + $descriptor = new QueryFieldDescriptor(); + $descriptor->setMiddlewareAnnotations(new MiddlewareAnnotations($annotations)); + $descriptor->setResolver(fn () => self::fail('Should not be called.')); + return $descriptor; + } + + private function stubFieldHandler(): FieldHandlerInterface + { + return new class implements FieldHandlerInterface { + public function handle(QueryFieldDescriptor $fieldDescriptor): FieldDefinition|null + { + return new FieldDefinition([ + 'name' => 'foo', + 'resolve' => $fieldDescriptor->getResolver(), + ]); + } + }; } } diff --git a/tests/Middlewares/AuthorizationInputFieldMiddlewareTest.php b/tests/Middlewares/AuthorizationInputFieldMiddlewareTest.php new file mode 100644 index 0000000000..9cfedf5b9a --- /dev/null +++ b/tests/Middlewares/AuthorizationInputFieldMiddlewareTest.php @@ -0,0 +1,122 @@ +createMock(AuthenticationServiceInterface::class); + $authenticationService->method('isLogged') + ->willReturn(true); + $authorizationService = $this->createMock(AuthorizationServiceInterface::class); + $authorizationService->method('isAllowed') + ->willReturn(true); + $middleware = new AuthorizationInputFieldMiddleware($authenticationService, $authorizationService); + + $descriptor = $this->stubDescriptor([new Logged(), new Right('test')]); + $descriptor->setResolver(fn () => 123); + + $field = $middleware->process($descriptor, $this->stubFieldHandler()); + + self::assertNotNull($field); + self::assertSame(123, $this->resolveField($field)); + } + + public function testHidesFieldForHideIfUnauthorized(): void + { + $middleware = new AuthorizationInputFieldMiddleware(new VoidAuthenticationService(), new VoidAuthorizationService()); + + $field = $middleware->process($this->stubDescriptor([new Logged(), new HideIfUnauthorized()]), $this->stubFieldHandler()); + + self::assertNull($field); + } + + public function testThrowsUnauthorizedExceptionWhenNotAuthorized(): void + { + $middleware = new AuthorizationInputFieldMiddleware(new VoidAuthenticationService(), new VoidAuthorizationService()); + + $field = $middleware->process($this->stubDescriptor([new Logged()]), $this->stubFieldHandler()); + + self::assertNotNull($field); + + $this->expectExceptionObject(MissingAuthorizationException::unauthorized()); + + $this->resolveField($field); + } + + public function testThrowsForbiddenExceptionWhenNotAuthorized(): void + { + $authenticationService = $this->createMock(AuthenticationServiceInterface::class); + $authenticationService->method('isLogged') + ->willReturn(true); + $middleware = new AuthorizationInputFieldMiddleware($authenticationService, new VoidAuthorizationService()); + + $field = $middleware->process($this->stubDescriptor([new Logged(), new Right('test')]), $this->stubFieldHandler()); + + self::assertNotNull($field); + + $this->expectExceptionObject(MissingAuthorizationException::forbidden()); + + $this->resolveField($field); + } + + /** + * @param MiddlewareAnnotationInterface[] $annotations + */ + private function stubDescriptor(array $annotations): InputFieldDescriptor + { + $descriptor = new InputFieldDescriptor(); + $descriptor->setMiddlewareAnnotations(new MiddlewareAnnotations($annotations)); + $descriptor->setTargetMethodOnSource('foo'); + $descriptor->setResolver(fn () => self::fail('Should not be called.')); + + return $descriptor; + } + + private function stubFieldHandler(): InputFieldHandlerInterface + { + return new class implements InputFieldHandlerInterface { + public function handle(InputFieldDescriptor $inputFieldDescriptor): InputField|null + { + return new InputField( + name: 'foo', + type: Type::string(), + arguments: [ + 'foo' => new SourceParameter(), + ], + originalResolver: $inputFieldDescriptor->getOriginalResolver(), + resolver: $inputFieldDescriptor->getResolver(), + comment: null, + isUpdate: false, + hasDefaultValue: false, + defaultValue: null + ); + } + }; + } + + private function resolveField(InputField $field): mixed + { + return $field->getResolve()( + new stdClass(), [], null, $this->createStub(ResolveInfo::class), + ); + } +} diff --git a/website/docs/annotations-reference.md b/website/docs/annotations-reference.md index 288413da9e..9dddfe0aff 100644 --- a/website/docs/annotations-reference.md +++ b/website/docs/annotations-reference.md @@ -150,6 +150,10 @@ value | *yes* | mixed | The value to return if the user is not au ## @HideIfUnauthorized +
This annotation only works when a Schema is used to handle exactly one use request. +If you serve your GraphQL API from long-running standalone servers (like Laravel Octane, Swoole, RoadRunner etc) and +share the same Schema instance between multiple requests, please avoid using @HideIfUnauthorized.
+ The `@HideIfUnauthorized` annotation is used to completely hide the query / mutation / field if the user is not authorized to access it (according to the `@Logged` and `@Right` annotations). diff --git a/website/docs/authentication-authorization.mdx b/website/docs/authentication-authorization.mdx index 2959713ddc..0e6a483a59 100644 --- a/website/docs/authentication-authorization.mdx +++ b/website/docs/authentication-authorization.mdx @@ -232,7 +232,7 @@ By default, a user analysing the GraphQL schema can see all queries/mutations/ty Some will be available to him and some won't. If you want to add an extra level of security (or if you want your schema to be kept secret to unauthorized users), -you can use the `@HideIfUnauthorized` annotation. +you can use the `@HideIfUnauthorized` annotation. Beware of [it's limitations](annotations-reference.md). The "HideIfUnauthorized" mode was the default mode in GraphQLite 3 and is optionnal from GraphQLite 4+. +
The "HideIfUnauthorized" mode was the default mode in GraphQLite 3 and is optional from GraphQLite 4+.
diff --git a/website/docs/field-middlewares.md b/website/docs/field-middlewares.md index e04f196442..07dafadb29 100644 --- a/website/docs/field-middlewares.md +++ b/website/docs/field-middlewares.md @@ -63,7 +63,9 @@ class QueryFieldDescriptor The role of a middleware is to analyze the `QueryFieldDescriptor` and modify it (or to directly return a `FieldDefinition`). -If you want the field to purely disappear, your middleware can return `null`. +If you want the field to purely disappear, your middleware can return `null`, although this should be used with caution: +field middlewares only get called once per Schema instance. If you use a long-running server (like Laravel Octane, Swoole, RoadRunner etc) +and share the same Schema instance across requests, you will not be able to hide fields based on request data. ## Annotations parsing From 5a9c03b23e3b89f359d26ecd1c461e7faf7059a7 Mon Sep 17 00:00:00 2001 From: oprypkhantc <54406427+oprypkhantc@users.noreply.github.com> Date: Mon, 13 Mar 2023 04:00:01 +0200 Subject: [PATCH 003/108] Add support for void return type (#574) * Add support for void return type * Extract VoidTypeMapper separately to make sure it only matches standalone usages in return types * Revert Types class and rename LastTopRootTypeMapper --- src/Mappers/Root/LastDelegatingTypeMapper.php | 42 +++++++++++++ .../Root/NullableTypeMapperAdapter.php | 11 ++-- src/Mappers/Root/VoidTypeMapper.php | 59 +++++++++++++++++++ src/SchemaFactory.php | 8 ++- src/Types/VoidType.php | 32 ++++++++++ tests/AbstractQueryProviderTest.php | 9 ++- .../AggregateControllerQueryProviderTest.php | 2 +- tests/FieldsBuilderTest.php | 7 ++- tests/Fixtures/TestController.php | 7 +++ .../Controllers/ButtonController.php | 6 ++ tests/GlobControllerQueryProviderTest.php | 2 +- tests/Integration/EndToEndTest.php | 37 +++++++++++- .../Root/NullableTypeMapperAdapterTest.php | 8 +-- tests/Types/VoidTypeTest.php | 34 +++++++++++ 14 files changed, 245 insertions(+), 19 deletions(-) create mode 100644 src/Mappers/Root/LastDelegatingTypeMapper.php create mode 100644 src/Mappers/Root/VoidTypeMapper.php create mode 100644 src/Types/VoidType.php create mode 100644 tests/Types/VoidTypeTest.php diff --git a/src/Mappers/Root/LastDelegatingTypeMapper.php b/src/Mappers/Root/LastDelegatingTypeMapper.php new file mode 100644 index 0000000000..efcd3739e7 --- /dev/null +++ b/src/Mappers/Root/LastDelegatingTypeMapper.php @@ -0,0 +1,42 @@ +next = $next; + } + + public function toGraphQLOutputType(Type $type, OutputType|null $subType, ReflectionMethod|ReflectionProperty $reflector, DocBlock $docBlockObj): OutputType&GraphQLType + { + return $this->next->toGraphQLOutputType($type, $subType, $reflector, $docBlockObj); + } + + public function toGraphQLInputType(Type $type, InputType|null $subType, string $argumentName, ReflectionMethod|ReflectionProperty $reflector, DocBlock $docBlockObj): InputType&GraphQLType + { + return $this->next->toGraphQLInputType($type, $subType, $argumentName, $reflector, $docBlockObj); + } + + public function mapNameToType(string $typeName): NamedType&GraphQLType + { + return $this->next->mapNameToType($typeName); + } +} diff --git a/src/Mappers/Root/NullableTypeMapperAdapter.php b/src/Mappers/Root/NullableTypeMapperAdapter.php index b435e20627..9983884e81 100644 --- a/src/Mappers/Root/NullableTypeMapperAdapter.php +++ b/src/Mappers/Root/NullableTypeMapperAdapter.php @@ -27,16 +27,14 @@ use function iterator_to_array; /** - * This root type mapper is the very first type mapper that must be called. - * It handles the "compound" types and is in charge of creating Union Types and detecting subTypes (for arrays) + * This root type mapper wraps types as "non nullable" if the corresponding PHPDoc type doesn't allow null. */ class NullableTypeMapperAdapter implements RootTypeMapperInterface { - private RootTypeMapperInterface $next; - - public function setNext(RootTypeMapperInterface $next): void + public function __construct( + private readonly RootTypeMapperInterface $next, + ) { - $this->next = $next; } public function toGraphQLOutputType(Type $type, OutputType|GraphQLType|null $subType, ReflectionMethod|ReflectionProperty $reflector, DocBlock $docBlockObj): OutputType&GraphQLType @@ -109,6 +107,7 @@ private function isNullable(Type $docBlockTypeHint): bool if ($docBlockTypeHint instanceof Null_ || $docBlockTypeHint instanceof Nullable) { return true; } + if ($docBlockTypeHint instanceof Compound) { foreach ($docBlockTypeHint as $type) { if ($this->isNullable($type)) { diff --git a/src/Mappers/Root/VoidTypeMapper.php b/src/Mappers/Root/VoidTypeMapper.php new file mode 100644 index 0000000000..97801df846 --- /dev/null +++ b/src/Mappers/Root/VoidTypeMapper.php @@ -0,0 +1,59 @@ +next->toGraphQLOutputType($type, $subType, $reflector, $docBlockObj); + } + + return self::getVoidType(); + } + + public function toGraphQLInputType(Type $type, InputType|null $subType, string $argumentName, ReflectionMethod|ReflectionProperty $reflector, DocBlock $docBlockObj): InputType&GraphQLType + { + if (! $type instanceof Void_) { + return $this->next->toGraphQLInputType($type, $subType, $argumentName, $reflector, $docBlockObj); + } + + throw CannotMapTypeException::mustBeOutputType(self::getVoidType()->name); + } + + public function mapNameToType(string $typeName): NamedType&GraphQLType + { + return match ($typeName) { + self::getVoidType()->name => self::getVoidType(), + default => $this->next->mapNameToType($typeName), + }; + } + + private static function getVoidType(): VoidType + { + return self::$voidType ??= new VoidType(); + } +} diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index b7dac89888..0c7fbf1bb0 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -31,10 +31,12 @@ use TheCodingMachine\GraphQLite\Mappers\Root\EnumTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\FinalRootTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\IteratorTypeMapper; +use TheCodingMachine\GraphQLite\Mappers\Root\LastDelegatingTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\MyCLabsEnumTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\NullableTypeMapperAdapter; use TheCodingMachine\GraphQLite\Mappers\Root\RootTypeMapperFactoryContext; use TheCodingMachine\GraphQLite\Mappers\Root\RootTypeMapperFactoryInterface; +use TheCodingMachine\GraphQLite\Mappers\Root\VoidTypeMapper; use TheCodingMachine\GraphQLite\Mappers\TypeMapperFactoryInterface; use TheCodingMachine\GraphQLite\Mappers\TypeMapperInterface; use TheCodingMachine\GraphQLite\Middlewares\AuthorizationFieldMiddleware; @@ -376,7 +378,9 @@ public function createSchema(): Schema $compositeTypeMapper = new CompositeTypeMapper(); $recursiveTypeMapper = new RecursiveTypeMapper($compositeTypeMapper, $namingStrategy, $namespacedCache, $typeRegistry, $annotationReader); - $topRootTypeMapper = new NullableTypeMapperAdapter(); + $lastTopRootTypeMapper = new LastDelegatingTypeMapper(); + $topRootTypeMapper = new NullableTypeMapperAdapter($lastTopRootTypeMapper); + $topRootTypeMapper = new VoidTypeMapper($topRootTypeMapper); $errorRootTypeMapper = new FinalRootTypeMapper($recursiveTypeMapper); $rootTypeMapper = new BaseTypeMapper($errorRootTypeMapper, $recursiveTypeMapper, $topRootTypeMapper); @@ -410,7 +414,7 @@ public function createSchema(): Schema $rootTypeMapper = new CompoundTypeMapper($rootTypeMapper, $topRootTypeMapper, $namingStrategy, $typeRegistry, $recursiveTypeMapper); $rootTypeMapper = new IteratorTypeMapper($rootTypeMapper, $topRootTypeMapper); - $topRootTypeMapper->setNext($rootTypeMapper); + $lastTopRootTypeMapper->setNext($rootTypeMapper); $argumentResolver = new ArgumentResolver(); diff --git a/src/Types/VoidType.php b/src/Types/VoidType.php new file mode 100644 index 0000000000..5bb3196823 --- /dev/null +++ b/src/Types/VoidType.php @@ -0,0 +1,32 @@ +setLogger(new ExceptionLogger()); - $topRootTypeMapper = new NullableTypeMapperAdapter(); + $lastTopRootTypeMapper = new LastDelegatingTypeMapper(); + $topRootTypeMapper = new NullableTypeMapperAdapter($lastTopRootTypeMapper); + $topRootTypeMapper = new VoidTypeMapper($topRootTypeMapper); $errorRootTypeMapper = new FinalRootTypeMapper($this->getTypeMapper()); $rootTypeMapper = new BaseTypeMapper( @@ -359,7 +363,8 @@ protected function buildRootTypeMapper(): RootTypeMapperInterface $rootTypeMapper = new IteratorTypeMapper($rootTypeMapper, $topRootTypeMapper); - $topRootTypeMapper->setNext($rootTypeMapper); + $lastTopRootTypeMapper->setNext($rootTypeMapper); + return $topRootTypeMapper; } diff --git a/tests/AggregateControllerQueryProviderTest.php b/tests/AggregateControllerQueryProviderTest.php index 03c049d5db..cda3e78aa8 100644 --- a/tests/AggregateControllerQueryProviderTest.php +++ b/tests/AggregateControllerQueryProviderTest.php @@ -40,6 +40,6 @@ public function has($id):bool $this->assertCount(9, $queries); $mutations = $aggregateQueryProvider->getMutations(); - $this->assertCount(1, $mutations); + $this->assertCount(2, $mutations); } } diff --git a/tests/FieldsBuilderTest.php b/tests/FieldsBuilderTest.php index 953ff24151..9070a8fb90 100644 --- a/tests/FieldsBuilderTest.php +++ b/tests/FieldsBuilderTest.php @@ -70,6 +70,7 @@ use TheCodingMachine\GraphQLite\Security\VoidAuthorizationService; use TheCodingMachine\GraphQLite\Annotations\Query; use TheCodingMachine\GraphQLite\Types\DateTimeType; +use TheCodingMachine\GraphQLite\Types\VoidType; class FieldsBuilderTest extends AbstractQueryProviderTest { @@ -136,7 +137,8 @@ public function testMutations(): void $mutations = $queryProvider->getMutations($controller); - $this->assertCount(1, $mutations); + $this->assertCount(2, $mutations); + $mutation = $mutations['mutation']; $this->assertSame('mutation', $mutation->name); @@ -145,6 +147,9 @@ public function testMutations(): void $this->assertInstanceOf(TestObject::class, $result); $this->assertEquals('42', $result->getTest()); + + $testVoidMutation = $mutations['testVoid']; + $this->assertInstanceOf(VoidType::class, $testVoidMutation->getType()); } public function testErrors(): void diff --git a/tests/Fixtures/TestController.php b/tests/Fixtures/TestController.php index b11a540863..ab69dbc14b 100644 --- a/tests/Fixtures/TestController.php +++ b/tests/Fixtures/TestController.php @@ -137,4 +137,11 @@ public function testFixComplexReturnType(): array { return ['42']; } + + /** + * @Mutation + */ + public function testVoid(): void + { + } } diff --git a/tests/Fixtures81/Integration/Controllers/ButtonController.php b/tests/Fixtures81/Integration/Controllers/ButtonController.php index 2d9b66d6f4..6b0eca2e7d 100644 --- a/tests/Fixtures81/Integration/Controllers/ButtonController.php +++ b/tests/Fixtures81/Integration/Controllers/ButtonController.php @@ -10,6 +10,7 @@ use TheCodingMachine\GraphQLite\Fixtures81\Integration\Models\Color; use TheCodingMachine\GraphQLite\Fixtures81\Integration\Models\Position; use TheCodingMachine\GraphQLite\Fixtures81\Integration\Models\Size; +use TheCodingMachine\GraphQLite\Types\ID; final class ButtonController { @@ -25,6 +26,11 @@ public function updateButton(Color $color, Size $size, Position $state): Button return new Button($color, $size, $state); } + #[Mutation] + public function deleteButton(ID $id): void + { + } + #[Mutation] public function singleEnum(Size $size): Size { diff --git a/tests/GlobControllerQueryProviderTest.php b/tests/GlobControllerQueryProviderTest.php index 1adc599bd7..48b5adcbb4 100644 --- a/tests/GlobControllerQueryProviderTest.php +++ b/tests/GlobControllerQueryProviderTest.php @@ -42,7 +42,7 @@ public function has($id):bool $this->assertCount(9, $queries); $mutations = $globControllerQueryProvider->getMutations(); - $this->assertCount(1, $mutations); + $this->assertCount(2, $mutations); } } diff --git a/tests/Integration/EndToEndTest.php b/tests/Integration/EndToEndTest.php index 335f2f32c1..a8d5fb9bad 100644 --- a/tests/Integration/EndToEndTest.php +++ b/tests/Integration/EndToEndTest.php @@ -48,9 +48,11 @@ use TheCodingMachine\GraphQLite\Mappers\Root\EnumTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\FinalRootTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\IteratorTypeMapper; +use TheCodingMachine\GraphQLite\Mappers\Root\LastDelegatingTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\MyCLabsEnumTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\NullableTypeMapperAdapter; use TheCodingMachine\GraphQLite\Mappers\Root\RootTypeMapperInterface; +use TheCodingMachine\GraphQLite\Mappers\Root\VoidTypeMapper; use TheCodingMachine\GraphQLite\Mappers\TypeMapperInterface; use TheCodingMachine\GraphQLite\Middlewares\AuthorizationFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\AuthorizationInputFieldMiddleware; @@ -297,7 +299,14 @@ public function createContainer(array $overloadedServices = []): ContainerInterf return new CachedDocBlockFactory(new Psr16Cache($arrayAdapter)); }, RootTypeMapperInterface::class => static function (ContainerInterface $container) { - return new NullableTypeMapperAdapter(); + return new VoidTypeMapper( + new NullableTypeMapperAdapter( + $container->get('topRootTypeMapper') + ) + ); + }, + 'topRootTypeMapper' => static function () { + return new LastDelegatingTypeMapper(); }, 'rootTypeMapper' => static function (ContainerInterface $container) { // These are in reverse order of execution @@ -363,7 +372,7 @@ public function createContainer(array $overloadedServices = []): ContainerInterf } $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(PorpaginasTypeMapper::class)); - $container->get(RootTypeMapperInterface::class)->setNext($container->get('rootTypeMapper')); + $container->get('topRootTypeMapper')->setNext($container->get('rootTypeMapper')); /*$container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new CompoundTypeMapper($container->get(RootTypeMapperInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class))); $container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new IteratorTypeMapper($container->get(RootTypeMapperInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class))); $container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new IteratorTypeMapper($container->get(RootTypeMapperInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class))); @@ -2490,4 +2499,28 @@ public function isAllowed(string $right, $subject = null): bool $data = $this->getSuccessResult($result); $this->assertSame(['graph', 'ql'], $data['updateTrickyProduct']['list']); } + + public function testEndToEndVoidResult(): void + { + $schema = $this->mainContainer->get(Schema::class); + assert($schema instanceof Schema); + + $gql = ' + mutation($id: ID!) { + deleteButton(id: $id) + } + '; + + $result = GraphQL::executeQuery( + $schema, + $gql, + variableValues: [ + 'id' => 123, + ], + ); + + self::assertSame([ + 'deleteButton' => null, + ], $this->getSuccessResult($result)); + } } diff --git a/tests/Mappers/Root/NullableTypeMapperAdapterTest.php b/tests/Mappers/Root/NullableTypeMapperAdapterTest.php index 9609b38026..ffd2b26360 100644 --- a/tests/Mappers/Root/NullableTypeMapperAdapterTest.php +++ b/tests/Mappers/Root/NullableTypeMapperAdapterTest.php @@ -62,9 +62,7 @@ public function testOnlyNull2(): void public function testNonNullableReturnedByWrappedMapper(): void { - $typeMapper = new NullableTypeMapperAdapter(); - - $typeMapper->setNext(new class implements RootTypeMapperInterface { + $next = new class implements RootTypeMapperInterface { public function toGraphQLOutputType(Type $type, ?OutputType $subType, $reflector, DocBlock $docBlockObj): OutputType&GraphQLType { @@ -80,7 +78,9 @@ public function mapNameToType(string $typeName): NamedType&GraphQLType { throw new \RuntimeException('Not implemented'); } - }); + }; + + $typeMapper = new NullableTypeMapperAdapter($next); $this->expectException(CannotMapTypeException::class); diff --git a/tests/Types/VoidTypeTest.php b/tests/Types/VoidTypeTest.php new file mode 100644 index 0000000000..40716f7ba5 --- /dev/null +++ b/tests/Types/VoidTypeTest.php @@ -0,0 +1,34 @@ +serialize(null)); + } + + public function testParseValue(): void + { + $this->expectExceptionObject(new GraphQLRuntimeException()); + + (new VoidType())->parseValue(null); + } + + public function testParseLiteral(): void + { + $this->expectExceptionObject(new GraphQLRuntimeException()); + + (new VoidType())->parseLiteral(new NullValueNode([])); + } +} From fcca09171f48060ad19bd0956e340e0959f97ce4 Mon Sep 17 00:00:00 2001 From: rusted-love <32598874+rusted-love@users.noreply.github.com> Date: Mon, 13 Mar 2023 23:44:49 +0200 Subject: [PATCH 004/108] Bump doctrine/annotations to v2 (#580) --- composer.json | 2 +- src/SchemaFactory.php | 8 +------- tests/Bootstrap.php | 10 ++-------- .../ClassWithInvalidExtendTypeAnnotation.php | 5 ++--- tests/Fixtures/BadClassType/TestType.php | 5 ++--- 5 files changed, 8 insertions(+), 22 deletions(-) diff --git a/composer.json b/composer.json index 9444c78996..e590e78770 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ "require": { "php": ">=8.1", "ext-json": "*", - "doctrine/annotations": "1.13.2", + "doctrine/annotations": "^2.0", "composer/package-versions-deprecated": "^1.8", "phpdocumentor/reflection-docblock": "^4.3 || ^5.0", "phpdocumentor/type-resolver": "^1.4", diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index 0c7fbf1bb0..921bdd64a0 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -224,13 +224,7 @@ public function setDoctrineAnnotationReader(Reader $annotationReader): self */ private function getDoctrineAnnotationReader(CacheItemPoolInterface $cache): Reader { - if ($this->doctrineAnnotationReader === null) { - AnnotationRegistry::registerLoader('class_exists'); - - return new PsrCachedReader(new DoctrineAnnotationReader(), $cache, true); - } - - return $this->doctrineAnnotationReader; + return $this->doctrineAnnotationReader ?? new PsrCachedReader(new DoctrineAnnotationReader(), $cache, true); } public function setAuthenticationService(AuthenticationServiceInterface $authenticationService): self diff --git a/tests/Bootstrap.php b/tests/Bootstrap.php index 2ff4503963..d2e0d9cc0c 100644 --- a/tests/Bootstrap.php +++ b/tests/Bootstrap.php @@ -1,9 +1,3 @@ Date: Fri, 17 Mar 2023 08:56:21 -0400 Subject: [PATCH 005/108] Support `doctrine/annotations` 1.x (#582) There are some package libraries that aren't quite up to date to `doctrine/annotations` 2. I don't see any need to not support 1.x for now. This causes dependency tree issues. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index e590e78770..7105fc4f66 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ "require": { "php": ">=8.1", "ext-json": "*", - "doctrine/annotations": "^2.0", + "doctrine/annotations": "^1.14 || ^2.0", "composer/package-versions-deprecated": "^1.8", "phpdocumentor/reflection-docblock": "^4.3 || ^5.0", "phpdocumentor/type-resolver": "^1.4", From 3908db21bfe6e974d342672c5ec3f4b86bcf4153 Mon Sep 17 00:00:00 2001 From: pvlg Date: Wed, 22 Mar 2023 23:23:01 +0200 Subject: [PATCH 006/108] Fixed a typo in example GraphQL query (#585) --- website/versioned_docs/version-6.1/inheritance-interfaces.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/versioned_docs/version-6.1/inheritance-interfaces.mdx b/website/versioned_docs/version-6.1/inheritance-interfaces.mdx index 083c4e1638..41a598481a 100644 --- a/website/versioned_docs/version-6.1/inheritance-interfaces.mdx +++ b/website/versioned_docs/version-6.1/inheritance-interfaces.mdx @@ -45,7 +45,7 @@ When writing your GraphQL query, you are able to use fragments to retrieve field ```graphql contact { name - ... User { + ... on User { email } } From 2382297dce1ef8e75fd6cb2ddf22c70dbff9fd0e Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Wed, 22 Mar 2023 17:23:43 -0400 Subject: [PATCH 007/108] Revert "Fixed a typo in example GraphQL query (#585)" (#586) This reverts commit 3908db21bfe6e974d342672c5ec3f4b86bcf4153. --- website/versioned_docs/version-6.1/inheritance-interfaces.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/versioned_docs/version-6.1/inheritance-interfaces.mdx b/website/versioned_docs/version-6.1/inheritance-interfaces.mdx index 41a598481a..083c4e1638 100644 --- a/website/versioned_docs/version-6.1/inheritance-interfaces.mdx +++ b/website/versioned_docs/version-6.1/inheritance-interfaces.mdx @@ -45,7 +45,7 @@ When writing your GraphQL query, you are able to use fragments to retrieve field ```graphql contact { name - ... on User { + ... User { email } } From eecb40627957d16ff24e08467de28e7d033cb15c Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Fri, 24 Mar 2023 17:49:43 -0400 Subject: [PATCH 008/108] Annotate SchemaFactory setDoctrineAnnotationReader as deprecated (#587) Support for Doctrine annotations has been deprecated for some time. This is just providing some additional IDE/code insight. --- src/SchemaFactory.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index 921bdd64a0..f38c008959 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -211,6 +211,9 @@ public function addParameterMiddleware(ParameterMiddlewareInterface $parameterMi return $this; } + /** + * @deprecated Use PHP8 Attributes instead + */ public function setDoctrineAnnotationReader(Reader $annotationReader): self { $this->doctrineAnnotationReader = $annotationReader; From e0ea6ff07a81e1419a65f5a4ef25409ee564a054 Mon Sep 17 00:00:00 2001 From: Mistral Oz <2956113+mistraloz@users.noreply.github.com> Date: Fri, 14 Apr 2023 21:46:55 +0200 Subject: [PATCH 009/108] fix: upgrade @docusaurus/core from 2.3.1 to 2.4.0 (#589) Snyk has created this PR to upgrade @docusaurus/core from 2.3.1 to 2.4.0. See this package in npm: https://www.npmjs.com/package/@docusaurus/core See this project in Snyk: https://app.snyk.io/org/miotcm/project/b0113452-4446-4d25-a677-c7a819a513e5?utm_source=github&utm_medium=referral&page=upgrade-pr Co-authored-by: snyk-bot --- website/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/package.json b/website/package.json index d20a8338e3..2bd0da3e75 100644 --- a/website/package.json +++ b/website/package.json @@ -8,7 +8,7 @@ }, "devDependencies": {}, "dependencies": { - "@docusaurus/core": "2.3.1", + "@docusaurus/core": "2.4.0", "@docusaurus/preset-classic": "2.3.1", "clsx": "^1.1.1", "mdx-mermaid": "^1.2.3", From 4a7cf040b9e5ce62e9597f1bab261994168f6a32 Mon Sep 17 00:00:00 2001 From: Mistral Oz <2956113+mistraloz@users.noreply.github.com> Date: Fri, 14 Apr 2023 21:48:23 +0200 Subject: [PATCH 010/108] fix: upgrade @docusaurus/preset-classic from 2.3.1 to 2.4.0 (#590) Snyk has created this PR to upgrade @docusaurus/preset-classic from 2.3.1 to 2.4.0. See this package in npm: https://www.npmjs.com/package/@docusaurus/preset-classic See this project in Snyk: https://app.snyk.io/org/miotcm/project/b0113452-4446-4d25-a677-c7a819a513e5?utm_source=github&utm_medium=referral&page=upgrade-pr Co-authored-by: snyk-bot Co-authored-by: Jacob Thomason --- website/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/package.json b/website/package.json index 2bd0da3e75..f8c29598c1 100644 --- a/website/package.json +++ b/website/package.json @@ -9,7 +9,7 @@ "devDependencies": {}, "dependencies": { "@docusaurus/core": "2.4.0", - "@docusaurus/preset-classic": "2.3.1", + "@docusaurus/preset-classic": "2.4.0", "clsx": "^1.1.1", "mdx-mermaid": "^1.2.3", "mermaid": "^8.12.0", From 63b035052e1f812f3b8e33b2c2c066c012ed612c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Apr 2023 17:00:15 -0400 Subject: [PATCH 011/108] Bump codecov/codecov-action from 3.1.1 to 3.1.3 (#593) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3.1.1 to 3.1.3. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v3.1.1...v3.1.3) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 70810c4f37..00fb60da6f 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -84,7 +84,7 @@ jobs: name: "codeCoverage" path: "build" - - uses: codecov/codecov-action@v3.1.1 # upload the coverage to codecov + - uses: codecov/codecov-action@v3.1.3 # upload the coverage to codecov with: fail_ci_if_error: true # optional (default = false) # Do not upload in forks, and only on php8, latest deps From 4e4b931c9798e5624fddefebbf835683883b0097 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Apr 2023 19:21:48 -0400 Subject: [PATCH 012/108] Update doctrine/coding-standard requirement from ^11.0 to ^11.0 || ^12.0 (#594) Updates the requirements on [doctrine/coding-standard](https://github.com/doctrine/coding-standard) to permit the latest version. - [Release notes](https://github.com/doctrine/coding-standard/releases) - [Commits](https://github.com/doctrine/coding-standard/compare/11.0.0...12.0.0) --- updated-dependencies: - dependency-name: doctrine/coding-standard dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 7105fc4f66..2196fcb0ad 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ }, "require-dev": { "beberlei/porpaginas": "^1.2 || ^2.0", - "doctrine/coding-standard": "^11.0", + "doctrine/coding-standard": "^11.0 || ^12.0", "ecodev/graphql-upload": "^7.0", "laminas/laminas-diactoros": "^2", "myclabs/php-enum": "^1.6.6", From 8d6a29248d7dc366815ce5e8677bd1e97bb9ae0b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 May 2023 08:09:19 -0400 Subject: [PATCH 013/108] Update laminas/laminas-diactoros requirement from ^2 to ^2 || ^3 (#597) Updates the requirements on [laminas/laminas-diactoros](https://github.com/laminas/laminas-diactoros) to permit the latest version. - [Release notes](https://github.com/laminas/laminas-diactoros/releases) - [Commits](https://github.com/laminas/laminas-diactoros/compare/2.0.0...3.0.0) --- updated-dependencies: - dependency-name: laminas/laminas-diactoros dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 2196fcb0ad..a511589605 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "beberlei/porpaginas": "^1.2 || ^2.0", "doctrine/coding-standard": "^11.0 || ^12.0", "ecodev/graphql-upload": "^7.0", - "laminas/laminas-diactoros": "^2", + "laminas/laminas-diactoros": "^2 || ^3", "myclabs/php-enum": "^1.6.6", "php-coveralls/php-coveralls": "^2.1", "phpstan/extension-installer": "^1.1", From 007887c09a0060bf7939601dc919e96100ee3907 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 May 2023 19:10:54 -0400 Subject: [PATCH 014/108] Bump JamesIves/github-pages-deploy-action from 4.4.0 to 4.4.2 (#600) Bumps [JamesIves/github-pages-deploy-action](https://github.com/JamesIves/github-pages-deploy-action) from 4.4.0 to 4.4.2. - [Release notes](https://github.com/JamesIves/github-pages-deploy-action/releases) - [Commits](https://github.com/JamesIves/github-pages-deploy-action/compare/v4.4.0...v4.4.2) --- updated-dependencies: - dependency-name: JamesIves/github-pages-deploy-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/doc_generation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/doc_generation.yml b/.github/workflows/doc_generation.yml index 8c7725d9db..43b9372c51 100644 --- a/.github/workflows/doc_generation.yml +++ b/.github/workflows/doc_generation.yml @@ -36,7 +36,7 @@ jobs: - name: "Deploy website" if: "${{ github.event_name == 'push' || github.event_name == 'release' }}" - uses: JamesIves/github-pages-deploy-action@v4.4.0 + uses: JamesIves/github-pages-deploy-action@v4.4.2 with: token: ${{ secrets.GITHUB_TOKEN }} branch: gh-pages # The branch the action should deploy to. From de089bae4717428097a2434ddaa00e369dbd9c91 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 31 May 2023 16:17:15 -0400 Subject: [PATCH 015/108] Bump codecov/codecov-action from 3.1.3 to 3.1.4 (#599) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3.1.3 to 3.1.4. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v3.1.3...v3.1.4) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 00fb60da6f..fc623f91bc 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -84,7 +84,7 @@ jobs: name: "codeCoverage" path: "build" - - uses: codecov/codecov-action@v3.1.3 # upload the coverage to codecov + - uses: codecov/codecov-action@v3.1.4 # upload the coverage to codecov with: fail_ci_if_error: true # optional (default = false) # Do not upload in forks, and only on php8, latest deps From bede9f81a47ceee828c60448a5ef85e0d5f26d83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Wed, 31 May 2023 22:18:29 +0200 Subject: [PATCH 016/108] Documenting new command in the Laravel package (#595) I added a new command in the Laravel package to export the schema. This can be useful in other tools (like graphql-codegen). This PR adds the documentation. The orginal PR is here: https://github.com/thecodingmachine/graphqlite-laravel/pull/44 --- website/docs/laravel-package-advanced.mdx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/website/docs/laravel-package-advanced.mdx b/website/docs/laravel-package-advanced.mdx index 42b0c9640f..4f63bedcf8 100644 --- a/website/docs/laravel-package-advanced.mdx +++ b/website/docs/laravel-package-advanced.mdx @@ -328,3 +328,16 @@ class User extends Model ``` + +## Export the schema from the CLI + +The extension comes with a special command: `graphqlite:export-schema`. + +Usage: + +```console +$ ./artisan graphqlite:export-schema --output=schema.graphql +``` + +This will export your GraphQL schema in SDL format. You can use this exported schema to import it in other +tools (like graphql-codegen). \ No newline at end of file From 4b6e19d3669a51b6c22eda7f2a552a2a3686a321 Mon Sep 17 00:00:00 2001 From: Snyk bot Date: Mon, 5 Jun 2023 21:55:14 +0100 Subject: [PATCH 017/108] fix: upgrade @docusaurus/preset-classic from 2.4.0 to 2.4.1 (#603) Snyk has created this PR to upgrade @docusaurus/preset-classic from 2.4.0 to 2.4.1. See this package in npm: https://www.npmjs.com/package/@docusaurus/preset-classic See this project in Snyk: https://app.snyk.io/org/miotcm/project/b0113452-4446-4d25-a677-c7a819a513e5?utm_source=github&utm_medium=referral&page=upgrade-pr --- website/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/package.json b/website/package.json index f8c29598c1..e6bcc76214 100644 --- a/website/package.json +++ b/website/package.json @@ -9,7 +9,7 @@ "devDependencies": {}, "dependencies": { "@docusaurus/core": "2.4.0", - "@docusaurus/preset-classic": "2.4.0", + "@docusaurus/preset-classic": "2.4.1", "clsx": "^1.1.1", "mdx-mermaid": "^1.2.3", "mermaid": "^8.12.0", From 11a2600763161f8984b3a68d71adced938157a50 Mon Sep 17 00:00:00 2001 From: Snyk bot Date: Mon, 5 Jun 2023 22:03:28 +0100 Subject: [PATCH 018/108] fix: upgrade @docusaurus/core from 2.4.0 to 2.4.1 (#602) Snyk has created this PR to upgrade @docusaurus/core from 2.4.0 to 2.4.1. See this package in npm: https://www.npmjs.com/package/@docusaurus/core See this project in Snyk: https://app.snyk.io/org/miotcm/project/b0113452-4446-4d25-a677-c7a819a513e5?utm_source=github&utm_medium=referral&page=upgrade-pr Co-authored-by: Jacob Thomason --- website/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/package.json b/website/package.json index e6bcc76214..857c65d2a7 100644 --- a/website/package.json +++ b/website/package.json @@ -8,7 +8,7 @@ }, "devDependencies": {}, "dependencies": { - "@docusaurus/core": "2.4.0", + "@docusaurus/core": "2.4.1", "@docusaurus/preset-classic": "2.4.1", "clsx": "^1.1.1", "mdx-mermaid": "^1.2.3", From 1edec8409271e162bcb5157abc351b43e80724a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane?= Date: Mon, 19 Jun 2023 21:12:23 +0200 Subject: [PATCH 019/108] Allow psr/container ^1.1 (#604) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index a511589605..d7be5e4997 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ "composer/package-versions-deprecated": "^1.8", "phpdocumentor/reflection-docblock": "^4.3 || ^5.0", "phpdocumentor/type-resolver": "^1.4", - "psr/container": "^2", + "psr/container": "^1.1 || ^2", "psr/http-factory": "^1", "psr/http-message": "^1.0.1", "psr/http-server-handler": "^1", From 6ce20bc7e3d9063fde068f8b4fed1ecb707dea51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Malte=20D=C3=B6lker?= Date: Sat, 24 Jun 2023 00:43:14 +0200 Subject: [PATCH 020/108] Enum descriptions and deprecation support (#606) * Fix EnumType exception message * Add description support to enum type * Add deprecation support to enum type --- src/Mappers/Root/EnumTypeMapper.php | 31 +++++++++++++++++++++- src/Types/EnumType.php | 40 +++++++++++++++++++++-------- 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/src/Mappers/Root/EnumTypeMapper.php b/src/Mappers/Root/EnumTypeMapper.php index 17caf51d66..2be7329b0d 100644 --- a/src/Mappers/Root/EnumTypeMapper.php +++ b/src/Mappers/Root/EnumTypeMapper.php @@ -10,6 +10,7 @@ use GraphQL\Type\Definition\Type as GraphQLType; use MyCLabs\Enum\Enum; use phpDocumentor\Reflection\DocBlock; +use phpDocumentor\Reflection\DocBlockFactory; use phpDocumentor\Reflection\Type; use phpDocumentor\Reflection\Types\Object_; use ReflectionClass; @@ -119,7 +120,35 @@ private function mapByClassName(string $enumClass): EnumType|null $reflectionEnum->isBacked() && (string) $reflectionEnum->getBackingType() === 'string'; - $type = new EnumType($enumClass, $typeName, $useValues); + $docBlockFactory = DocBlockFactory::createInstance(); + + $enumDescription = null; + $docComment = $reflectionEnum->getDocComment(); + if ($docComment) { + $docBlock = $docBlockFactory->create($docComment); + $enumDescription = $docBlock->getSummary(); + } + + $enumCaseDescriptions = []; + $enumCaseDeprecationReasons = []; + foreach ($reflectionEnum->getCases() as $reflectionEnumCase) { + $docComment = $reflectionEnumCase->getDocComment(); + if ($docComment) { + $docBlock = $docBlockFactory->create($docComment); + $enumCaseDescription = $docBlock->getSummary(); + + $enumCaseDescriptions[$reflectionEnumCase->getName()] = $enumCaseDescription; + $deprecation = $docBlock->getTagsByName('deprecated')[0] ?? null; + + if ($deprecation) { + $enumCaseDeprecationReasons[$reflectionEnumCase->getName()] = (string) $deprecation; + } + } + } + + /** @var array $enumCaseDescriptions */ + /** @var array $enumCaseDeprecationReasons */ + $type = new EnumType($enumClass, $typeName, $enumDescription, $enumCaseDescriptions, $enumCaseDeprecationReasons, $useValues); return $this->cacheByName[$type->name] = $this->cache[$enumClass] = $type; } diff --git a/src/Types/EnumType.php b/src/Types/EnumType.php index f04c02b1ae..260d40fa38 100644 --- a/src/Types/EnumType.php +++ b/src/Types/EnumType.php @@ -17,19 +17,37 @@ */ class EnumType extends BaseEnumType { - /** @param class-string $enumName */ - public function __construct(string $enumName, string $typeName, private readonly bool $useValues = false) - { - $values = []; + /** + * @param class-string $enumName + * @param array $caseDescriptions + * @param array $caseDeprecationReasons + */ + public function __construct( + string $enumName, + string $typeName, + ?string $description, + array $caseDescriptions, + array $caseDeprecationReasons, + private readonly bool $useValues = false, + ) { + $typeValues = []; foreach ($enumName::cases() as $case) { - /** @var UnitEnum $case */ - $values[$this->serialize($case)] = ['value' => $case]; + $key = $this->serialize($case); + $typeValues[$key] = [ + 'name' => $key, + 'value' => $case, + 'description' => $caseDescriptions[$case->name] ?? null, + 'deprecationReason' => $caseDeprecationReasons[$case->name] ?? null, + ]; } - parent::__construct([ - 'name' => $typeName, - 'values' => $values, - ]); + parent::__construct( + [ + 'name' => $typeName, + 'values' => $typeValues, + 'description' => $description, + ] + ); } // phpcs:disable SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint @@ -38,7 +56,7 @@ public function __construct(string $enumName, string $typeName, private readonly public function serialize($value): string { if (! $value instanceof UnitEnum) { - throw new InvalidArgumentException('Expected a Myclabs Enum instance'); + throw new InvalidArgumentException('Expected a UnitEnum instance'); } if (! $this->useValues) { From 55c3fd7eab4948c1668177f144d62ca181f26c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Malte=20D=C3=B6lker?= Date: Sat, 24 Jun 2023 00:43:51 +0200 Subject: [PATCH 021/108] Make type namespaces available in RootTypeMapperFactoryContext (#605) --- src/Mappers/Root/RootTypeMapperFactoryContext.php | 11 +++++++++++ src/SchemaFactory.php | 1 + tests/RootTypeMapperFactoryContextTest.php | 5 +++++ 3 files changed, 17 insertions(+) diff --git a/src/Mappers/Root/RootTypeMapperFactoryContext.php b/src/Mappers/Root/RootTypeMapperFactoryContext.php index 254a5ac081..1206f64abb 100644 --- a/src/Mappers/Root/RootTypeMapperFactoryContext.php +++ b/src/Mappers/Root/RootTypeMapperFactoryContext.php @@ -11,6 +11,7 @@ use TheCodingMachine\GraphQLite\NamingStrategyInterface; use TheCodingMachine\GraphQLite\TypeRegistry; use TheCodingMachine\GraphQLite\Types\TypeResolver; +use TheCodingMachine\GraphQLite\Utils\Namespaces\NS; /** * A context class containing a number of classes created on the fly by SchemaFactory. @@ -18,6 +19,9 @@ */ final class RootTypeMapperFactoryContext { + /** + * @param iterable $typeNamespaces + */ public function __construct( private readonly AnnotationReader $annotationReader, private readonly TypeResolver $typeResolver, @@ -26,6 +30,7 @@ public function __construct( private readonly RecursiveTypeMapperInterface $recursiveTypeMapper, private readonly ContainerInterface $container, private readonly CacheInterface $cache, + private readonly iterable $typeNamespaces, private readonly int|null $globTTL, private readonly int|null $mapTTL = null, ) { @@ -66,6 +71,12 @@ public function getCache(): CacheInterface return $this->cache; } + /** @return iterable */ + public function getTypeNamespaces(): iterable + { + return $this->typeNamespaces; + } + public function getGlobTTL(): int|null { return $this->globTTL; diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index f38c008959..f25d584f05 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -399,6 +399,7 @@ public function createSchema(): Schema $recursiveTypeMapper, $this->container, $namespacedCache, + $nsList, $this->globTTL, ); diff --git a/tests/RootTypeMapperFactoryContextTest.php b/tests/RootTypeMapperFactoryContextTest.php index c7b7834d96..e33665c307 100644 --- a/tests/RootTypeMapperFactoryContextTest.php +++ b/tests/RootTypeMapperFactoryContextTest.php @@ -8,6 +8,7 @@ use Symfony\Component\Cache\Simple\ArrayCache; use TheCodingMachine\GraphQLite\Containers\EmptyContainer; use TheCodingMachine\GraphQLite\Mappers\Root\RootTypeMapperFactoryContext; +use TheCodingMachine\GraphQLite\Utils\Namespaces\NS; class RootTypeMapperFactoryContextTest extends AbstractQueryProviderTest { @@ -18,6 +19,7 @@ public function testContext(): void $namingStrategy = new NamingStrategy(); $container = new EmptyContainer(); $arrayCache = new Psr16Cache(new ArrayAdapter()); + $nsList = [$this->getNamespaceFactory()->createNamespace('namespace')]; $context = new RootTypeMapperFactoryContext( $this->getAnnotationReader(), @@ -27,6 +29,7 @@ public function testContext(): void $this->getTypeMapper(), $container, $arrayCache, + $nsList, self::GLOB_TTL_SECONDS ); @@ -37,6 +40,8 @@ public function testContext(): void $this->assertSame($this->getTypeMapper(), $context->getRecursiveTypeMapper()); $this->assertSame($container, $context->getContainer()); $this->assertSame($arrayCache, $context->getCache()); + $this->assertSame($nsList, $context->getTypeNamespaces()); + $this->assertContainsOnlyInstancesOf(NS::class, $context->getTypeNamespaces()); $this->assertSame(self::GLOB_TTL_SECONDS, $context->getGlobTTL()); $this->assertNull($context->getMapTTL()); } From 2ec0f684fc7213461b21769751937125a60eebaa Mon Sep 17 00:00:00 2001 From: Christophe Vergne Date: Wed, 28 Jun 2023 02:58:49 +0200 Subject: [PATCH 022/108] Fix UseInputType example as an attribute (#608) --- website/versioned_docs/version-6.1/input-types.mdx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/website/versioned_docs/version-6.1/input-types.mdx b/website/versioned_docs/version-6.1/input-types.mdx index f357d87512..06754d97a1 100644 --- a/website/versioned_docs/version-6.1/input-types.mdx +++ b/website/versioned_docs/version-6.1/input-types.mdx @@ -249,9 +249,7 @@ Let's say you want to force a parameter to be of type "ID", you can use this: ```php #[Factory] -#[UseInputType(for: "$id", inputType:"ID!")] -public function getProductById(string $id): Product -{ +public function getProductById(#[UseInputType(inputType:"ID!")] string $id): Product { return $this->productRepository->findById($id); } ``` From 18c50602d0100bd37b617dc86e73c2d0d237436f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Jul 2023 16:27:20 -0400 Subject: [PATCH 023/108] Bump JamesIves/github-pages-deploy-action from 4.4.2 to 4.4.3 (#609) Bumps [JamesIves/github-pages-deploy-action](https://github.com/jamesives/github-pages-deploy-action) from 4.4.2 to 4.4.3. - [Release notes](https://github.com/jamesives/github-pages-deploy-action/releases) - [Commits](https://github.com/jamesives/github-pages-deploy-action/compare/v4.4.2...v4.4.3) --- updated-dependencies: - dependency-name: JamesIves/github-pages-deploy-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/doc_generation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/doc_generation.yml b/.github/workflows/doc_generation.yml index 43b9372c51..12834aa8d1 100644 --- a/.github/workflows/doc_generation.yml +++ b/.github/workflows/doc_generation.yml @@ -36,7 +36,7 @@ jobs: - name: "Deploy website" if: "${{ github.event_name == 'push' || github.event_name == 'release' }}" - uses: JamesIves/github-pages-deploy-action@v4.4.2 + uses: JamesIves/github-pages-deploy-action@v4.4.3 with: token: ${{ secrets.GITHUB_TOKEN }} branch: gh-pages # The branch the action should deploy to. From 92ed2abbd69ad92c96e886ce7f28c6b636686d3c Mon Sep 17 00:00:00 2001 From: oprypkhantc <54406427+oprypkhantc@users.noreply.github.com> Date: Mon, 31 Jul 2023 12:45:37 +0300 Subject: [PATCH 024/108] Immutability (#584) * Make parts of graphqlite immutable * Revert previous versions docs * Refactor getters, PHPStan, codestyle * Fix phpcbf's broken Cloneable * Fix return types in docs * Fix "test" class names used for resolvers * Refactor GlobAnnotationsCache to be immutable --- phpstan.neon | 4 + src/Context/Context.php | 4 +- src/Context/ContextInterface.php | 4 +- src/FieldsBuilder.php | 196 +++++++++-------- src/InputField.php | 12 +- src/InputFieldDescriptor.php | 174 ++++++++------- src/Mappers/AbstractTypeMapper.php | 14 +- src/Mappers/GlobAnnotationsCache.php | 68 +++--- src/Mappers/GlobExtendAnnotationsCache.php | 11 +- src/Mappers/Parameters/TypeHandler.php | 22 +- src/Mappers/StaticTypeMapper.php | 50 ++--- .../AuthorizationFieldMiddleware.php | 4 +- .../AuthorizationInputFieldMiddleware.php | 2 +- .../BadExpressionInSecurityException.php | 2 +- src/Middlewares/MagicPropertyResolver.php | 44 ++-- src/Middlewares/ResolverInterface.php | 13 +- src/Middlewares/SecurityFieldMiddleware.php | 12 +- .../SecurityInputFieldMiddleware.php | 10 +- src/Middlewares/ServiceResolver.php | 9 +- .../SourceInputPropertyResolver.php | 42 ++-- src/Middlewares/SourceMethodResolver.php | 51 +++++ src/Middlewares/SourcePropertyResolver.php | 39 ++-- src/Middlewares/SourceResolver.php | 53 ----- src/Middlewares/SourceResolverInterface.php | 16 -- src/Parameters/InputTypeParameter.php | 22 +- src/Parameters/InputTypeProperty.php | 19 +- src/Parameters/PrefetchDataParameter.php | 55 ++++- src/PrefetchBuffer.php | 14 +- src/QueryField.php | 95 ++++----- src/QueryFieldDescriptor.php | 200 ++++++++++-------- src/Utils/Cloneable.php | 45 ++++ .../Fixtures/TestControllerWithArrayParam.php | 2 + tests/Mappers/RecursiveTypeMapperTest.php | 45 ++-- tests/Mappers/StaticTypeMapperTest.php | 97 ++++----- .../AuthorizationFieldMiddlewareTest.php | 17 +- .../AuthorizationInputFieldMiddlewareTest.php | 21 +- tests/Middlewares/FieldMiddlewarePipeTest.php | 13 +- .../InputFieldMiddlewarePipeTest.php | 18 +- .../Middlewares/MagicPropertyResolverTest.php | 19 +- tests/Middlewares/SourceResolverTest.php | 8 +- tests/QueryFieldDescriptorTest.php | 43 ++-- tests/QueryFieldTest.php | 4 +- website/docs/custom-types.mdx | 22 +- website/docs/field-middlewares.md | 22 +- 44 files changed, 898 insertions(+), 739 deletions(-) create mode 100644 src/Middlewares/SourceMethodResolver.php delete mode 100644 src/Middlewares/SourceResolver.php delete mode 100644 src/Middlewares/SourceResolverInterface.php create mode 100644 src/Utils/Cloneable.php diff --git a/phpstan.neon b/phpstan.neon index 02fbe8e28d..b496200bf0 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -17,6 +17,10 @@ parameters: - "#Parameter .* of class ReflectionMethod constructor expects string(\\|null)?, object\\|string given.#" - "#PHPDoc tag @throws with type Psr\\\\SimpleCache\\\\InvalidArgumentException is not subtype of Throwable#" - '#Variable \$context might not be defined.#' + # TODO: fix these in the resolver refactor PR that follows; it'll be initialized in the constructor + - '#Class TheCodingMachine\\GraphQLite\\(QueryFieldDescriptor|InputFieldDescriptor) has an uninitialized readonly property \$(originalResolver|resolver)\. Assign it in the constructor.#' + - '#Readonly property TheCodingMachine\\GraphQLite\\(QueryFieldDescriptor|InputFieldDescriptor)::\$(originalResolver|resolver) is assigned outside of the constructor\.#' + - '#Property TheCodingMachine\\GraphQLite\\(QueryFieldDescriptor|InputFieldDescriptor)::\$resolver \(callable\) in isset\(\) is not nullable\.#' - message: '#Parameter .* of class GraphQL\\Error\\Error constructor expects#' path: src/Exceptions/WebonyxErrorHandler.php diff --git a/src/Context/Context.php b/src/Context/Context.php index c231a98867..9a8ddddcce 100644 --- a/src/Context/Context.php +++ b/src/Context/Context.php @@ -5,8 +5,8 @@ namespace TheCodingMachine\GraphQLite\Context; use SplObjectStorage; +use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; use TheCodingMachine\GraphQLite\PrefetchBuffer; -use TheCodingMachine\GraphQLite\QueryField; /** * A context class that should be passed to the Webonyx executor. @@ -24,7 +24,7 @@ public function __construct() * Returns the prefetch buffer associated to the field $field. * (the buffer is created on the fly if it does not exist yet). */ - public function getPrefetchBuffer(QueryField $field): PrefetchBuffer + public function getPrefetchBuffer(ParameterInterface $field): PrefetchBuffer { if ($this->prefetchBuffers->offsetExists($field)) { $prefetchBuffer = $this->prefetchBuffers->offsetGet($field); diff --git a/src/Context/ContextInterface.php b/src/Context/ContextInterface.php index 407f502a83..a7df360d06 100644 --- a/src/Context/ContextInterface.php +++ b/src/Context/ContextInterface.php @@ -4,8 +4,8 @@ namespace TheCodingMachine\GraphQLite\Context; +use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; use TheCodingMachine\GraphQLite\PrefetchBuffer; -use TheCodingMachine\GraphQLite\QueryField; /** * A context class that should be passed to the Webonyx executor. @@ -18,5 +18,5 @@ interface ContextInterface * Returns the prefetch buffer associated to the field $field. * (the buffer is created on the fly if it does not exist yet). */ - public function getPrefetchBuffer(QueryField $field): PrefetchBuffer; + public function getPrefetchBuffer(ParameterInterface $field): PrefetchBuffer; } diff --git a/src/FieldsBuilder.php b/src/FieldsBuilder.php index b126d410b1..0b436667a6 100644 --- a/src/FieldsBuilder.php +++ b/src/FieldsBuilder.php @@ -353,11 +353,7 @@ private function getFieldsByMethodAnnotations(string|object $controller, Reflect $description = $queryAnnotation->getDescription(); } - $fieldDescriptor = new QueryFieldDescriptor(); - $fieldDescriptor->setRefMethod($refMethod); - $docBlockObj = $this->cachedDocBlockFactory->getDocBlock($refMethod); - $fieldDescriptor->setDeprecationReason($this->getDeprecationReason($docBlockObj)); $name = $queryAnnotation->getName() ?: $this->namingStrategy->getFieldNameFromMethodName($methodName); @@ -365,13 +361,31 @@ private function getFieldsByMethodAnnotations(string|object $controller, Reflect $description = $docBlockObj->getSummary() . "\n" . $docBlockObj->getDescription()->render(); } - $fieldDescriptor->setName($name); - $fieldDescriptor->setComment(trim($description)); + $outputType = $queryAnnotation->getOutputType(); + if ($outputType) { + try { + $type = $this->typeResolver->mapNameToOutputType($outputType); + } catch (CannotMapTypeExceptionInterface $e) { + $e->addReturnInfo($refMethod); + throw $e; + } + } else { + $type = $this->typeMapper->mapReturnType($refMethod, $docBlockObj); + } + + $fieldDescriptor = new QueryFieldDescriptor( + name: $name, + type: $type, + comment: trim($description), + deprecationReason: $this->getDeprecationReason($docBlockObj), + refMethod: $refMethod, + ); [$prefetchMethodName, $prefetchArgs, $prefetchRefMethod] = $this->getPrefetchMethodInfo($refClass, $refMethod, $queryAnnotation); if ($prefetchMethodName) { - $fieldDescriptor->setPrefetchMethodName($prefetchMethodName); - $fieldDescriptor->setPrefetchParameters($prefetchArgs); + $fieldDescriptor = $fieldDescriptor + ->withPrefetchMethodName($prefetchMethodName) + ->withPrefetchParameters($prefetchArgs); } $parameters = $refMethod->getParameters(); @@ -388,31 +402,19 @@ private function getFieldsByMethodAnnotations(string|object $controller, Reflect $args = $this->mapParameters($parameters, $docBlockObj); - $fieldDescriptor->setParameters($args); + $fieldDescriptor = $fieldDescriptor->withParameters($args); - $outputType = $queryAnnotation->getOutputType(); - if ($outputType) { - try { - $type = $this->typeResolver->mapNameToOutputType($outputType); - } catch (CannotMapTypeExceptionInterface $e) { - $e->addReturnInfo($refMethod); - throw $e; - } - } else { - $type = $this->typeMapper->mapReturnType($refMethod, $docBlockObj); - } if (is_string($controller)) { - $fieldDescriptor->setTargetMethodOnSource($methodName); + $fieldDescriptor = $fieldDescriptor->withTargetMethodOnSource($refMethod->getDeclaringClass()->getName(), $methodName); } else { $callable = [$controller, $methodName]; assert(is_callable($callable)); - $fieldDescriptor->setCallable($callable); + $fieldDescriptor = $fieldDescriptor->withCallable($callable); } - $fieldDescriptor->setType($type); - $fieldDescriptor->setInjectSource($injectSource); - - $fieldDescriptor->setMiddlewareAnnotations($this->annotationReader->getMiddlewareAnnotations($refMethod)); + $fieldDescriptor = $fieldDescriptor + ->withInjectSource($injectSource) + ->withMiddlewareAnnotations($this->annotationReader->getMiddlewareAnnotations($refMethod)); $field = $this->fieldMiddleware->process($fieldDescriptor, new class implements FieldHandlerInterface { public function handle(QueryFieldDescriptor $fieldDescriptor): FieldDefinition|null @@ -459,11 +461,8 @@ private function getFieldsByPropertyAnnotations(string|object $controller, Refle $description = $queryAnnotation->getDescription(); } - $fieldDescriptor = new QueryFieldDescriptor(); - $fieldDescriptor->setRefProperty($refProperty); - $docBlock = $this->cachedDocBlockFactory->getDocBlock($refProperty); - $fieldDescriptor->setDeprecationReason($this->getDeprecationReason($docBlock)); + $name = $queryAnnotation->getName() ?: $refProperty->getName(); if (! $description) { @@ -477,15 +476,6 @@ private function getFieldsByPropertyAnnotations(string|object $controller, Refle } } - $fieldDescriptor->setName($name); - $fieldDescriptor->setComment(trim($description)); - - [$prefetchMethodName, $prefetchArgs] = $this->getPrefetchMethodInfo($refClass, $refProperty, $queryAnnotation); - if ($prefetchMethodName) { - $fieldDescriptor->setPrefetchMethodName($prefetchMethodName); - $fieldDescriptor->setPrefetchParameters($prefetchArgs); - } - $outputType = $queryAnnotation->getOutputType(); if ($outputType) { $type = $this->typeResolver->mapNameToOutputType($outputType); @@ -494,18 +484,32 @@ private function getFieldsByPropertyAnnotations(string|object $controller, Refle assert($type instanceof OutputType); } - $fieldDescriptor->setType($type); - $fieldDescriptor->setInjectSource(false); + $fieldDescriptor = new QueryFieldDescriptor( + name: $name, + type: $type, + comment: trim($description), + deprecationReason: $this->getDeprecationReason($docBlock), + refProperty: $refProperty, + ); + + [$prefetchMethodName, $prefetchArgs] = $this->getPrefetchMethodInfo($refClass, $refProperty, $queryAnnotation); + if ($prefetchMethodName) { + $fieldDescriptor = $fieldDescriptor + ->withPrefetchMethodName($prefetchMethodName) + ->withPrefetchParameters($prefetchArgs); + } if (is_string($controller)) { - $fieldDescriptor->setTargetPropertyOnSource($refProperty->getName()); + $fieldDescriptor = $fieldDescriptor->withTargetPropertyOnSource($refProperty->getDeclaringClass()->getName(), $refProperty->getName()); } else { - $fieldDescriptor->setCallable(static function () use ($controller, $refProperty) { + $fieldDescriptor = $fieldDescriptor->withCallable(static function () use ($controller, $refProperty) { return PropertyAccessor::getValue($controller, $refProperty->getName()); }); } - $fieldDescriptor->setMiddlewareAnnotations($this->annotationReader->getMiddlewareAnnotations($refProperty)); + $fieldDescriptor = $fieldDescriptor + ->withInjectSource(false) + ->withMiddlewareAnnotations($this->annotationReader->getMiddlewareAnnotations($refProperty)); $field = $this->fieldMiddleware->process($fieldDescriptor, new class implements FieldHandlerInterface { public function handle(QueryFieldDescriptor $fieldDescriptor): FieldDefinition|null @@ -574,33 +578,26 @@ private function getQueryFieldsFromSourceFields(array $sourceFields, ReflectionC $queryList = []; foreach ($sourceFields as $sourceField) { - $fieldDescriptor = new QueryFieldDescriptor(); - $fieldDescriptor->setName($sourceField->getName()); - if (! $sourceField->shouldFetchFromMagicProperty()) { try { $refMethod = $this->getMethodFromPropertyName($objectRefClass, $sourceField->getSourceName() ?? $sourceField->getName()); } catch (FieldNotFoundException $e) { throw FieldNotFoundException::wrapWithCallerInfo($e, $refClass->getName()); } - $fieldDescriptor->setRefMethod($refMethod); + $methodName = $refMethod->getName(); - $fieldDescriptor->setTargetMethodOnSource($methodName); $docBlockObj = $this->cachedDocBlockFactory->getDocBlock($refMethod); $docBlockComment = rtrim($docBlockObj->getSummary() . "\n" . $docBlockObj->getDescription()->render()); $deprecated = $docBlockObj->getTagsByName('deprecated'); if (count($deprecated) >= 1) { - $fieldDescriptor->setDeprecationReason(trim((string) $deprecated[0])); + $deprecationReason = trim((string) $deprecated[0]); } $description = $sourceField->getDescription() ?? $docBlockComment; - $fieldDescriptor->setComment($description); $args = $this->mapParameters($refMethod->getParameters(), $docBlockObj, $sourceField); - $fieldDescriptor->setParameters($args); - $outputType = $sourceField->getOutputType(); $phpTypeStr = $sourceField->getPhpType(); if ($outputType !== null) { @@ -610,10 +607,18 @@ private function getQueryFieldsFromSourceFields(array $sourceFields, ReflectionC } else { $type = $this->typeMapper->mapReturnType($refMethod, $docBlockObj); } - } else { - $fieldDescriptor->setMagicProperty($sourceField->getSourceName() ?? $sourceField->getName()); - $fieldDescriptor->setComment($sourceField->getDescription()); + $fieldDescriptor = new QueryFieldDescriptor( + name: $sourceField->getName(), + type: $type, + parameters: $args, + targetClass: $refMethod->getDeclaringClass()->getName(), + targetMethodOnSource: $methodName, + comment: $description, + deprecationReason: $deprecationReason ?? null, + refMethod: $refMethod, + ); + } else { $outputType = $sourceField->getOutputType(); if ($outputType !== null) { $type = $this->resolveOutputType($outputType, $refClass, $sourceField); @@ -624,11 +629,19 @@ private function getQueryFieldsFromSourceFields(array $sourceFields, ReflectionC $type = $this->resolvePhpType($phpTypeStr, $refClass, $magicGefRefMethod); } + + $fieldDescriptor = new QueryFieldDescriptor( + name: $sourceField->getName(), + type: $type, + targetClass: $refClass->getName(), + magicProperty: $sourceField->getSourceName() ?? $sourceField->getName(), + comment: $sourceField->getDescription(), + ); } - $fieldDescriptor->setType($type); - $fieldDescriptor->setInjectSource(false); - $fieldDescriptor->setMiddlewareAnnotations($sourceField->getMiddlewareAnnotations()); + $fieldDescriptor = $fieldDescriptor + ->withInjectSource(false) + ->withMiddlewareAnnotations($sourceField->getMiddlewareAnnotations()); $field = $this->fieldMiddleware->process($fieldDescriptor, new class implements FieldHandlerInterface { public function handle(QueryFieldDescriptor $fieldDescriptor): FieldDefinition|null @@ -850,12 +863,6 @@ private function getInputFieldsByMethodAnnotations(string|object $controller, Re $description = $docBlockObj->getSummary() . "\n" . $docBlockObj->getDescription()->render(); } - $inputFieldDescriptor = new InputFieldDescriptor(); - $inputFieldDescriptor->setRefMethod($refMethod); - $inputFieldDescriptor->setIsUpdate($isUpdate); - $inputFieldDescriptor->setName($name); - $inputFieldDescriptor->setComment(trim($description)); - $parameters = $refMethod->getParameters(); if ($injectSource === true) { $firstParameter = array_shift($parameters); @@ -865,8 +872,6 @@ private function getInputFieldsByMethodAnnotations(string|object $controller, Re /** @var array $args */ $args = $this->mapParameters($parameters, $docBlockObj); - $inputFieldDescriptor->setParameters($args); - $inputType = $fieldAnnotations->getInputType(); if ($inputType) { try { @@ -882,17 +887,28 @@ private function getInputFieldsByMethodAnnotations(string|object $controller, Re } } - $inputFieldDescriptor->setHasDefaultValue($isUpdate); - $inputFieldDescriptor->setDefaultValue($args[$name]->getDefaultValue()); + assert($type instanceof InputType); + + $inputFieldDescriptor = new InputFieldDescriptor( + name: $name, + type: $type, + parameters: $args, + comment: trim($description), + refMethod: $refMethod, + isUpdate: $isUpdate, + ); + + $inputFieldDescriptor = $inputFieldDescriptor + ->withHasDefaultValue($isUpdate) + ->withDefaultValue($args[$name]->getDefaultValue()); $constructerParameters = $this->getClassConstructParameterNames($refClass); if (! in_array($name, $constructerParameters)) { - $inputFieldDescriptor->setTargetMethodOnSource($methodName); + $inputFieldDescriptor = $inputFieldDescriptor->withTargetMethodOnSource($refMethod->getDeclaringClass()->getName(), $methodName); } - assert($type instanceof InputType); - $inputFieldDescriptor->setType($type); - $inputFieldDescriptor->setInjectSource($injectSource); - $inputFieldDescriptor->setMiddlewareAnnotations($this->annotationReader->getMiddlewareAnnotations($refMethod)); + $inputFieldDescriptor = $inputFieldDescriptor + ->withInjectSource($injectSource) + ->withMiddlewareAnnotations($this->annotationReader->getMiddlewareAnnotations($refMethod)); $field = $this->inputFieldMiddleware->process($inputFieldDescriptor, new class implements InputFieldHandlerInterface { public function handle(InputFieldDescriptor $inputFieldDescriptor): InputField|null @@ -967,27 +983,27 @@ private function getInputFieldsByPropertyAnnotations(string|object $controller, $inputProperty->getDefaultValue(), ); } else { - // setters and properties - $inputFieldDescriptor = new InputFieldDescriptor(); - $inputFieldDescriptor->setRefProperty($refProperty); - $inputFieldDescriptor->setIsUpdate($isUpdate); - $inputFieldDescriptor->setHasDefaultValue($inputProperty->hasDefaultValue()); - $inputFieldDescriptor->setDefaultValue($inputProperty->getDefaultValue()); - - $inputFieldDescriptor->setName($inputProperty->getName()); - $inputFieldDescriptor->setComment(trim($description)); - - $inputFieldDescriptor->setParameters([$inputProperty->getName() => $inputProperty]); - $type = $inputProperty->getType(); if (! $inputType && $isUpdate && $type instanceof NonNull) { $type = $type->getWrappedType(); } assert($type instanceof InputType); - $inputFieldDescriptor->setType($type); - $inputFieldDescriptor->setInjectSource(false); - $inputFieldDescriptor->setTargetPropertyOnSource($refProperty->getName()); - $inputFieldDescriptor->setMiddlewareAnnotations($this->annotationReader->getMiddlewareAnnotations($refProperty)); + + // setters and properties + $inputFieldDescriptor = new InputFieldDescriptor( + name: $inputProperty->getName(), + type: $type, + parameters: [$inputProperty->getName() => $inputProperty], + targetClass: $refProperty->getDeclaringClass()->getName(), + targetPropertyOnSource: $refProperty->getName(), + injectSource: false, + comment: trim($description), + middlewareAnnotations: $this->annotationReader->getMiddlewareAnnotations($refProperty), + refProperty: $refProperty, + isUpdate: $isUpdate, + hasDefaultValue: $inputProperty->hasDefaultValue(), + defaultValue: $inputProperty->getDefaultValue(), + ); $field = $this->inputFieldMiddleware->process($inputFieldDescriptor, new class implements InputFieldHandlerInterface { public function handle(InputFieldDescriptor $inputFieldDescriptor): InputField|null diff --git a/src/InputField.php b/src/InputField.php index 24555df7ec..16ad51cfc6 100644 --- a/src/InputField.php +++ b/src/InputField.php @@ -14,7 +14,6 @@ use GraphQL\Type\Definition\Type; use TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException; use TheCodingMachine\GraphQLite\Middlewares\ResolverInterface; -use TheCodingMachine\GraphQLite\Middlewares\SourceResolverInterface; use TheCodingMachine\GraphQLite\Parameters\MissingArgumentException; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; use TheCodingMachine\GraphQLite\Parameters\SourceParameter; @@ -54,12 +53,9 @@ public function __construct(string $name, InputType $type, array $arguments, Res } if ($originalResolver !== null && $resolver !== null) { - $this->resolve = function ($source, array $args, $context, ResolveInfo $info) use ($arguments, $originalResolver, $resolver) { - if ($originalResolver instanceof SourceResolverInterface) { - $originalResolver->setObject($source); - } + $this->resolve = function (object $source, array $args, $context, ResolveInfo $info) use ($arguments, $originalResolver, $resolver) { $toPassArgs = $this->paramsToArguments($arguments, $source, $args, $context, $info, $resolver); - $result = $resolver(...$toPassArgs); + $result = $resolver($source, ...$toPassArgs); try { $this->assertInputType($result); @@ -72,7 +68,7 @@ public function __construct(string $name, InputType $type, array $arguments, Res }; } else { $this->forConstructorHydration = true; - $this->resolve = function ($source, array $args, $context, ResolveInfo $info) use ($arguments) { + $this->resolve = function (object|null $source, array $args, $context, ResolveInfo $info) use ($arguments) { $result = $arguments[$this->name]->resolve($source, $args, $context, $info); $this->assertInputType($result); return $result; @@ -143,7 +139,7 @@ public static function fromFieldDescriptor(InputFieldDescriptor $fieldDescriptor if ($fieldDescriptor->isInjectSource() === true) { $arguments = ['__graphqlite_source' => new SourceParameter()] + $arguments; } - $fieldDescriptor->setParameters($arguments); + $fieldDescriptor = $fieldDescriptor->withParameters($arguments); return self::fromDescriptor($fieldDescriptor); } diff --git a/src/InputFieldDescriptor.php b/src/InputFieldDescriptor.php index d7e79f5620..a3eff9abd0 100644 --- a/src/InputFieldDescriptor.php +++ b/src/InputFieldDescriptor.php @@ -5,7 +5,6 @@ namespace TheCodingMachine\GraphQLite; use GraphQL\Type\Definition\InputType; -use GraphQL\Type\Definition\NullableType; use GraphQL\Type\Definition\Type; use ReflectionMethod; use ReflectionProperty; @@ -13,9 +12,11 @@ use TheCodingMachine\GraphQLite\Middlewares\ResolverInterface; use TheCodingMachine\GraphQLite\Middlewares\ServiceResolver; use TheCodingMachine\GraphQLite\Middlewares\SourceInputPropertyResolver; -use TheCodingMachine\GraphQLite\Middlewares\SourceResolver; +use TheCodingMachine\GraphQLite\Middlewares\SourceMethodResolver; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; +use TheCodingMachine\GraphQLite\Utils\Cloneable; +use function assert; use function is_callable; /** @@ -25,44 +26,45 @@ */ class InputFieldDescriptor { - private string $name; - /** @var (InputType&Type)|(InputType&Type&NullableType) */ - private InputType&Type $type; - /** @var array */ - private array $parameters = []; - /** @var callable|null */ - private $callable; - private string|null $targetMethodOnSource; - private string|null $targetPropertyOnSource; + use Cloneable; - /** - * Implement in future PR - */ - // private ?string $magicProperty; + private readonly ResolverInterface $originalResolver; + /** @var callable */ + private readonly mixed $resolver; /** - * Whether we should inject the source as the first parameter or not. + * @param array $parameters + * @param callable|null $callable + * @param bool $injectSource Whether we should inject the source as the first parameter or not. */ - private bool $injectSource = false; - private string|null $comment = null; - private MiddlewareAnnotations $middlewareAnnotations; - private ReflectionMethod $refMethod; - private ReflectionProperty $refProperty; - private ResolverInterface|null $originalResolver = null; - /** @var callable */ - private $resolver; - private bool $isUpdate = false; - private bool $hasDefaultValue = false; - private mixed $defaultValue = null; + public function __construct( + private readonly string $name, + private readonly InputType&Type $type, + private readonly array $parameters = [], + private readonly mixed $callable = null, + private readonly string|null $targetClass = null, + private readonly string|null $targetMethodOnSource = null, + private readonly string|null $targetPropertyOnSource = null, + private readonly bool $injectSource = false, + private readonly string|null $comment = null, + private readonly MiddlewareAnnotations $middlewareAnnotations = new MiddlewareAnnotations([]), + private readonly ReflectionMethod|null $refMethod = null, + private readonly ReflectionProperty|null $refProperty = null, + private readonly bool $isUpdate = false, + private readonly bool $hasDefaultValue = false, + private readonly mixed $defaultValue = null, + ) + { + } public function isUpdate(): bool { return $this->isUpdate; } - public function setIsUpdate(bool $isUpdate): void + public function withIsUpdate(bool $isUpdate): self { - $this->isUpdate = $isUpdate; + return $this->with(isUpdate: $isUpdate); } public function hasDefaultValue(): bool @@ -70,9 +72,9 @@ public function hasDefaultValue(): bool return $this->hasDefaultValue; } - public function setHasDefaultValue(bool $hasDefaultValue): void + public function withHasDefaultValue(bool $hasDefaultValue): self { - $this->hasDefaultValue = $hasDefaultValue; + return $this->with(hasDefaultValue: $hasDefaultValue); } public function getDefaultValue(): mixed @@ -80,9 +82,9 @@ public function getDefaultValue(): mixed return $this->defaultValue; } - public function setDefaultValue(mixed $defaultValue): void + public function withDefaultValue(mixed $defaultValue): self { - $this->defaultValue = $defaultValue; + return $this->with(defaultValue: $defaultValue); } public function getName(): string @@ -90,20 +92,19 @@ public function getName(): string return $this->name; } - public function setName(string $name): void + public function withName(string $name): self { - $this->name = $name; + return $this->with(name: $name); } - /** @return ((InputType&Type)|(InputType&Type&NullableType)) */ public function getType(): InputType&Type { return $this->type; } - public function setType(InputType&Type $type): void + public function withType(InputType&Type $type): self { - $this->type = $type; + return $this->with(type: $type); } /** @return array */ @@ -113,9 +114,9 @@ public function getParameters(): array } /** @param array $parameters */ - public function setParameters(array $parameters): void + public function withParameters(array $parameters): self { - $this->parameters = $parameters; + return $this->with(parameters: $parameters); } /** @@ -123,46 +124,52 @@ public function setParameters(array $parameters): void * This should not be used in the context of a field middleware. * Use getResolver/setResolver if you want to wrap the resolver in another method. */ - public function setCallable(callable $callable): void + public function withCallable(callable $callable): self { - if ($this->originalResolver !== null) { - throw new GraphQLRuntimeException('You cannot modify the callable via setCallable because it was already used. You can still wrap the callable using getResolver/setResolver'); + if (isset($this->originalResolver)) { + throw new GraphQLRuntimeException('You cannot modify the target method via withCallable because it was already used. You can still wrap the callable using getResolver/withResolver'); } - $this->callable = $callable; - $this->targetMethodOnSource = null; - $this->targetPropertyOnSource = null; - // To be enabled in a future PR // $this->magicProperty = null; + return $this->with( + callable: $callable, + targetClass: null, + targetMethodOnSource: null, + targetPropertyOnSource: null, + ); } - public function setTargetMethodOnSource(string $targetMethodOnSource): void + public function withTargetMethodOnSource(string $className, string $targetMethodOnSource): self { - if ($this->originalResolver !== null) { - throw new GraphQLRuntimeException('You cannot modify the target method via setTargetMethodOnSource because it was already used. You can still wrap the callable using getResolver/setResolver'); + if (isset($this->originalResolver)) { + throw new GraphQLRuntimeException('You cannot modify the target method via withTargetMethodOnSource because it was already used. You can still wrap the callable using getResolver/withResolver'); } - $this->callable = null; - $this->targetMethodOnSource = $targetMethodOnSource; - $this->targetPropertyOnSource = null; - // To be enabled in a future PR // $this->magicProperty = null; + return $this->with( + callable: null, + targetClass: $className, + targetMethodOnSource: $targetMethodOnSource, + targetPropertyOnSource: null, + ); } - public function setTargetPropertyOnSource(string|null $targetPropertyOnSource): void + public function withTargetPropertyOnSource(string $className, string|null $targetPropertyOnSource): self { - if ($this->originalResolver !== null) { - throw new GraphQLRuntimeException('You cannot modify the target method via setTargetMethodOnSource because it was already used. You can still wrap the callable using getResolver/setResolver'); + if (isset($this->originalResolver)) { + throw new GraphQLRuntimeException('You cannot modify the target method via withTargetMethodOnSource because it was already used. You can still wrap the callable using getResolver/withResolver'); } - $this->callable = null; - $this->targetMethodOnSource = null; - $this->targetPropertyOnSource = $targetPropertyOnSource; - // To be enabled in a future PR // $this->magicProperty = null; + return $this->with( + callable: null, + targetClass: $className, + targetMethodOnSource: null, + targetPropertyOnSource: $targetPropertyOnSource, + ); } public function isInjectSource(): bool @@ -170,9 +177,9 @@ public function isInjectSource(): bool return $this->injectSource; } - public function setInjectSource(bool $injectSource): void + public function withInjectSource(bool $injectSource): self { - $this->injectSource = $injectSource; + return $this->with(injectSource: $injectSource); } public function getComment(): string|null @@ -180,9 +187,9 @@ public function getComment(): string|null return $this->comment; } - public function setComment(string|null $comment): void + public function withComment(string|null $comment): self { - $this->comment = $comment; + return $this->with(comment: $comment); } public function getMiddlewareAnnotations(): MiddlewareAnnotations @@ -190,29 +197,29 @@ public function getMiddlewareAnnotations(): MiddlewareAnnotations return $this->middlewareAnnotations; } - public function setMiddlewareAnnotations(MiddlewareAnnotations $middlewareAnnotations): void + public function withMiddlewareAnnotations(MiddlewareAnnotations $middlewareAnnotations): self { - $this->middlewareAnnotations = $middlewareAnnotations; + return $this->with(middlewareAnnotations: $middlewareAnnotations); } - public function getRefMethod(): ReflectionMethod + public function getRefMethod(): ReflectionMethod|null { return $this->refMethod; } - public function setRefMethod(ReflectionMethod $refMethod): void + public function withRefMethod(ReflectionMethod $refMethod): self { - $this->refMethod = $refMethod; + return $this->with(refMethod: $refMethod); } - public function getRefProperty(): ReflectionProperty + public function getRefProperty(): ReflectionProperty|null { return $this->refProperty; } - public function setRefProperty(ReflectionProperty $refProperty): void + public function withRefProperty(ReflectionProperty $refProperty): self { - $this->refProperty = $refProperty; + return $this->with(refProperty: $refProperty); } /** @@ -229,14 +236,18 @@ public function getOriginalResolver(): ResolverInterface $callable = $this->callable; $this->originalResolver = new ServiceResolver($callable); } elseif ($this->targetMethodOnSource !== null) { - $this->originalResolver = new SourceResolver($this->targetMethodOnSource); + assert($this->targetClass !== null); + + $this->originalResolver = new SourceMethodResolver($this->targetClass, $this->targetMethodOnSource); } elseif ($this->targetPropertyOnSource !== null) { - $this->originalResolver = new SourceInputPropertyResolver($this->targetPropertyOnSource); + assert($this->targetClass !== null); + + $this->originalResolver = new SourceInputPropertyResolver($this->targetClass, $this->targetPropertyOnSource); // } elseif ($this->magicProperty !== null) { // Enable magic properties in a future PR // $this->originalResolver = new MagicInputPropertyResolver($this->magicProperty); } else { - throw new GraphQLRuntimeException('The InputFieldDescriptor should be passed either a resolve method (via setCallable) or a target method on source object (via setTargetMethodOnSource).'); + throw new GraphQLRuntimeException('The InputFieldDescriptor should be passed either a resolve method (via withCallable) or a target method on source object (via withTargetMethodOnSource).'); } return $this->originalResolver; @@ -248,16 +259,16 @@ public function getOriginalResolver(): ResolverInterface */ public function getResolver(): callable { - if ($this->resolver === null) { + if (! isset($this->resolver)) { $this->resolver = $this->getOriginalResolver(); } return $this->resolver; } - public function setResolver(callable $resolver): void + public function withResolver(callable $resolver): self { - $this->resolver = $resolver; + return $this->with(resolver: $resolver); } /* @@ -268,12 +279,11 @@ public function setResolver(callable $resolver): void * public function setMagicProperty(string $magicProperty): void * { * if ($this->originalResolver !== null) { - * throw new GraphQLRuntimeException('You cannot modify the target method via setMagicProperty because it was already used. You can still wrap the callable using getResolver/setResolver'); + * throw new GraphQLRuntimeException('You cannot modify the target method via withMagicProperty because it was already used. You can still wrap the callable using getResolver/withResolver'); * } - * $this->callable = null; * $this->targetMethodOnSource = null; * $this->targetPropertyOnSource = null; - * $this->magicProperty = $magicProperty; + * return $this->with(magicProperty: $magicProperty); * } */ } diff --git a/src/Mappers/AbstractTypeMapper.php b/src/Mappers/AbstractTypeMapper.php index 12f2e93955..37df90ea2e 100644 --- a/src/Mappers/AbstractTypeMapper.php +++ b/src/Mappers/AbstractTypeMapper.php @@ -127,7 +127,7 @@ private function buildMap(): GlobTypeMapperCache $type = $this->annotationReader->getTypeAnnotation($refClass); if ($type !== null) { $typeName = $this->namingStrategy->getOutputTypeName($className, $type); - $annotationsCache->setType($type->getClass(), $typeName, $type->isDefault()); + $annotationsCache = $annotationsCache->withType($type->getClass(), $typeName, $type->isDefault()); $containsAnnotations = true; } @@ -139,7 +139,7 @@ private function buildMap(): GlobTypeMapperCache } $this->registeredInputs[$inputName] = $refClass->getName(); - $annotationsCache->registerInput($inputName, $className, $input); + $annotationsCache = $annotationsCache->registerInput($inputName, $className, $input); $containsAnnotations = true; } @@ -154,7 +154,7 @@ private function buildMap(): GlobTypeMapperCache if ($factory !== null) { [$inputName, $className] = $this->inputTypeUtils->getInputTypeNameAndClassName($method); - $annotationsCache->registerFactory($method->getName(), $inputName, $className, $factory->isDefault(), $refClass->getName()); + $annotationsCache = $annotationsCache->registerFactory($method->getName(), $inputName, $className, $factory->isDefault(), $refClass->getName()); $containsAnnotations = true; } @@ -164,7 +164,7 @@ private function buildMap(): GlobTypeMapperCache continue; } - $annotationsCache->registerDecorator($method->getName(), $decorator->getInputTypeName(), $refClass->getName()); + $annotationsCache = $annotationsCache->registerDecorator($method->getName(), $decorator->getInputTypeName(), $refClass->getName()); $containsAnnotations = true; } @@ -196,8 +196,6 @@ private function buildMapClassToExtendTypeArray(): GlobExtendTypeMapperCache continue; } $annotationsCache = $this->mapClassToExtendAnnotationsCache->get($refClass, function () use ($refClass) { - $extendAnnotationsCache = new GlobExtendAnnotationsCache(); - $extendType = $this->annotationReader->getExtendTypeAnnotation($refClass); if ($extendType !== null) { @@ -221,9 +219,7 @@ private function buildMapClassToExtendTypeArray(): GlobExtendTypeMapperCache } // FIXME: $extendClassName === NULL!!!!!! - $extendAnnotationsCache->setExtendType($extendClassName, $typeName); - - return $extendAnnotationsCache; + return new GlobExtendAnnotationsCache($extendClassName, $typeName); } return 'nothing'; diff --git a/src/Mappers/GlobAnnotationsCache.php b/src/Mappers/GlobAnnotationsCache.php index 207fc08401..426a91cbe2 100644 --- a/src/Mappers/GlobAnnotationsCache.php +++ b/src/Mappers/GlobAnnotationsCache.php @@ -5,6 +5,7 @@ namespace TheCodingMachine\GraphQLite\Mappers; use TheCodingMachine\GraphQLite\Annotations\Input; +use TheCodingMachine\GraphQLite\Utils\Cloneable; /** * An object containing a description of ALL annotations relevant to GlobTypeMapper for a given class. @@ -13,28 +14,32 @@ */ final class GlobAnnotationsCache { - /** @var class-string|null */ - private string|null $typeClassName = null; + use Cloneable; - private string|null $typeName = null; - - private bool $default; - - /** @var array|null, 2:bool, 3:class-string}> An array mapping a factory method name to an input name / class name / default flag / declaring class */ - private array $factories = []; - - /** @var array}> An array mapping a decorator method name to an input name / declaring class */ - private array $decorators = []; - - /** @var array, 1: bool, 2: string|null, 3: bool}> An array mapping an input type name to an input name / declaring class */ - private array $inputs = []; + /** + * @param class-string|null $typeClassName + * @param array|null, 2:bool, 3:class-string}> $factories An array mapping a factory method name to an input name / class name / default flag / declaring class + * @param array}> $decorators An array mapping a decorator method name to an input name / declaring class + * @param array, 1: bool, 2: string|null, 3: bool}> $inputs An array mapping an input type name to an input name / declaring class + */ + public function __construct( + private readonly string|null $typeClassName = null, + private readonly string|null $typeName = null, + private readonly bool $default = false, + private readonly array $factories = [], + private readonly array $decorators = [], + private readonly array $inputs = [], + ) { + } /** @param class-string $className */ - public function setType(string $className, string $typeName, bool $isDefault): void + public function withType(string $className, string $typeName, bool $isDefault): self { - $this->typeClassName = $className; - $this->typeName = $typeName; - $this->default = $isDefault; + return $this->with( + typeClassName: $className, + typeName: $typeName, + default: $isDefault, + ); } /** @return class-string|null */ @@ -57,15 +62,25 @@ public function isDefault(): bool * @param class-string|null $className * @param class-string $declaringClass */ - public function registerFactory(string $methodName, string $inputName, string|null $className, bool $isDefault, string $declaringClass): void + public function registerFactory(string $methodName, string $inputName, string|null $className, bool $isDefault, string $declaringClass): self { - $this->factories[$methodName] = [$inputName, $className, $isDefault, $declaringClass]; + return $this->with( + factories: [ + ...$this->factories, + $methodName => [$inputName, $className, $isDefault, $declaringClass], + ], + ); } /** @param class-string $declaringClass */ - public function registerDecorator(string $methodName, string $inputName, string $declaringClass): void + public function registerDecorator(string $methodName, string $inputName, string $declaringClass): self { - $this->decorators[$methodName] = [$inputName, $declaringClass]; + return $this->with( + decorators: [ + ...$this->decorators, + $methodName => [$inputName, $declaringClass], + ], + ); } /** @return array|null, 2:bool, 3:class-string}> */ @@ -85,9 +100,14 @@ public function getDecorators(): array * * @param class-string $className */ - public function registerInput(string $name, string $className, Input $input): void + public function registerInput(string $name, string $className, Input $input): self { - $this->inputs[$name] = [$className, $input->isDefault(), $input->getDescription(), $input->isUpdate()]; + return $this->with( + inputs: [ + ...$this->inputs, + $name => [$className, $input->isDefault(), $input->getDescription(), $input->isUpdate()], + ], + ); } /** diff --git a/src/Mappers/GlobExtendAnnotationsCache.php b/src/Mappers/GlobExtendAnnotationsCache.php index 4ed59fe6de..b99d1acd28 100644 --- a/src/Mappers/GlobExtendAnnotationsCache.php +++ b/src/Mappers/GlobExtendAnnotationsCache.php @@ -11,14 +11,11 @@ */ final class GlobExtendAnnotationsCache { - private string|null $extendTypeClassName = null; - - private string $extendTypeName; - - public function setExtendType(string|null $className, string $typeName): void + public function __construct( + private string|null $extendTypeClassName, + private string $extendTypeName, + ) { - $this->extendTypeClassName = $className; - $this->extendTypeName = $typeName; } public function getExtendTypeClassName(): string|null diff --git a/src/Mappers/Parameters/TypeHandler.php b/src/Mappers/Parameters/TypeHandler.php index 47ceea4681..2a5db4cbd4 100644 --- a/src/Mappers/Parameters/TypeHandler.php +++ b/src/Mappers/Parameters/TypeHandler.php @@ -198,7 +198,14 @@ public function mapParameter(ReflectionParameter $parameter, DocBlock $docBlock, $defaultValue = $parameter->getDefaultValue(); } - return new InputTypeParameter($parameter->getName(), $type, $hasDefaultValue, $defaultValue, $this->argumentResolver); + return new InputTypeParameter( + name: $parameter->getName(), + type: $type, + description: null, + hasDefaultValue: $hasDefaultValue, + defaultValue: $defaultValue, + argumentResolver: $this->argumentResolver, + ); } /** @@ -291,10 +298,15 @@ public function mapInputProperty( $hasDefault = $defaultValue !== null || $isNullable; $fieldName = $argumentName ?? $refProperty->getName(); - $inputProperty = new InputTypeProperty($refProperty->getName(), $fieldName, $inputType, $hasDefault, $defaultValue, $this->argumentResolver); - $inputProperty->setDescription(trim($docBlockComment)); - - return $inputProperty; + return new InputTypeProperty( + propertyName: $refProperty->getName(), + fieldName: $fieldName, + type: $inputType, + description: trim($docBlockComment), + hasDefaultValue: $hasDefault, + defaultValue: $defaultValue, + argumentResolver: $this->argumentResolver, + ); } /** diff --git a/src/Mappers/StaticTypeMapper.php b/src/Mappers/StaticTypeMapper.php index d41d7cdb37..009abb4f90 100644 --- a/src/Mappers/StaticTypeMapper.php +++ b/src/Mappers/StaticTypeMapper.php @@ -18,56 +18,34 @@ use TheCodingMachine\GraphQLite\Types\ResolvableMutableInputInterface; use function array_keys; +use function array_map; use function array_reduce; /** * A simple implementation of the TypeMapperInterface that expects mapping to be passed in a setter. - * - * Note: no constructor argument as this results in a loop most of the time. */ final class StaticTypeMapper implements TypeMapperInterface { /** @var array */ - private array $types = []; - - /** - * An array mapping a fully qualified class name to the matching TypeInterface - * - * @param array $types - */ - public function setTypes(array $types): void - { - foreach ($types as $className => $type) { - $type = $this->castOutputTypeToMutable($type); - $this->types[$className] = $type; - } - } - - /** @var array */ - private array $inputTypes = []; - - /** - * An array mapping a fully qualified class name to the matching InputTypeInterface - * - * @param array $inputTypes - */ - public function setInputTypes(array $inputTypes): void - { - $this->inputTypes = $inputTypes; - } + private readonly array $types; /** @var array */ - private array $notMappedTypes = []; + private readonly array $notMappedTypes; /** - * An array containing ObjectType or ResolvableMutableInputInterface instances that are not mapped by default to any class. - * ObjectType not linked to any type by default will have to be accessed using the outputType attribute of the annotations. - * - * @param array $types + * @param array $types An array mapping a fully qualified class name to the matching TypeInterface + * @param array $inputTypes An array mapping a fully qualified class name to the matching InputTypeInterface + * @param array $notMappedTypes An array containing ObjectType or ResolvableMutableInputInterface instances that are not mapped by default to any class. + * ObjectType not linked to any type by default will have to be accessed using the outputType attribute of the annotations. */ - public function setNotMappedTypes(array $types): void + public function __construct( + array $types = [], + private readonly array $inputTypes = [], + array $notMappedTypes = [], + ) { - $this->notMappedTypes = array_reduce($types, function ($result, Type $type) { + $this->types = array_map(fn (ObjectType|InterfaceType $type) => $this->castOutputTypeToMutable($type), $types); + $this->notMappedTypes = array_reduce($notMappedTypes, function ($result, Type $type) { if ($type instanceof ObjectType || $type instanceof InterfaceType) { $type = $this->castOutputTypeToMutable($type); } diff --git a/src/Middlewares/AuthorizationFieldMiddleware.php b/src/Middlewares/AuthorizationFieldMiddleware.php index 59c6a25422..e1e8acde06 100644 --- a/src/Middlewares/AuthorizationFieldMiddleware.php +++ b/src/Middlewares/AuthorizationFieldMiddleware.php @@ -57,7 +57,7 @@ public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandler if ($failWith !== null && $type instanceof NonNull && $failWith->getValue() === null) { $type = $type->getWrappedType(); assert($type instanceof OutputType); - $queryFieldDescriptor->setType($type); + $queryFieldDescriptor = $queryFieldDescriptor->withType($type); } // When using the same Schema instance for multiple subsequent requests, this middleware will only @@ -69,7 +69,7 @@ public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandler $resolver = $queryFieldDescriptor->getResolver(); - $queryFieldDescriptor->setResolver(function (...$args) use ($rightAnnotation, $loggedAnnotation, $failWith, $resolver) { + $queryFieldDescriptor = $queryFieldDescriptor->withResolver(function (...$args) use ($rightAnnotation, $loggedAnnotation, $failWith, $resolver) { if ($this->isAuthorized($loggedAnnotation, $rightAnnotation)) { return $resolver(...$args); } diff --git a/src/Middlewares/AuthorizationInputFieldMiddleware.php b/src/Middlewares/AuthorizationInputFieldMiddleware.php index 2151ccfda6..3e78b87960 100644 --- a/src/Middlewares/AuthorizationInputFieldMiddleware.php +++ b/src/Middlewares/AuthorizationInputFieldMiddleware.php @@ -50,7 +50,7 @@ public function process(InputFieldDescriptor $inputFieldDescriptor, InputFieldHa $resolver = $inputFieldDescriptor->getResolver(); - $inputFieldDescriptor->setResolver(function (...$args) use ($rightAnnotation, $loggedAnnotation, $resolver) { + $inputFieldDescriptor = $inputFieldDescriptor->withResolver(function (...$args) use ($rightAnnotation, $loggedAnnotation, $resolver) { if ($this->isAuthorized($loggedAnnotation, $rightAnnotation)) { return $resolver(...$args); } diff --git a/src/Middlewares/BadExpressionInSecurityException.php b/src/Middlewares/BadExpressionInSecurityException.php index 6cc0b9c5af..1a1fda25bb 100644 --- a/src/Middlewares/BadExpressionInSecurityException.php +++ b/src/Middlewares/BadExpressionInSecurityException.php @@ -17,7 +17,7 @@ class BadExpressionInSecurityException extends Exception public static function wrapException(Throwable $e, QueryFieldDescriptor|InputFieldDescriptor $fieldDescriptor): self { $refMethod = $fieldDescriptor->getRefMethod(); - $message = 'An error occurred while evaluating expression in @Security annotation of method "' . $refMethod->getDeclaringClass()->getName() . '::' . $refMethod->getName() . '": ' . $e->getMessage(); + $message = 'An error occurred while evaluating expression in @Security annotation of method "' . $refMethod?->getDeclaringClass()?->getName() . '::' . $refMethod?->getName() . '": ' . $e->getMessage(); return new self($message, $e->getCode(), $e); } diff --git a/src/Middlewares/MagicPropertyResolver.php b/src/Middlewares/MagicPropertyResolver.php index 502ba1afcf..43cbe498ce 100644 --- a/src/Middlewares/MagicPropertyResolver.php +++ b/src/Middlewares/MagicPropertyResolver.php @@ -6,51 +6,45 @@ use TheCodingMachine\GraphQLite\GraphQLRuntimeException; -use function assert; -use function get_class; use function method_exists; /** - * A class that represents a magic property of an object. - * The object can be modified after class invocation. + * Resolves field by getting the value of $propertyName from the source object through magic getter __get. * * @internal */ -final class MagicPropertyResolver implements SourceResolverInterface +final class MagicPropertyResolver implements ResolverInterface { - private object|null $object = null; - - public function __construct(private string $propertyName) - { + public function __construct( + private readonly string $className, + private readonly string $propertyName, + ) { } - public function setObject(object $object): void + public function executionSource(object|null $source): object { - $this->object = $object; - } - - public function getObject(): object - { - assert($this->object !== null); + if ($source === null) { + throw new GraphQLRuntimeException('You must provide a source for MagicPropertyResolver.'); + } - return $this->object; + return $source; } - public function __invoke(mixed ...$args): mixed + public function __invoke(object|null $source, mixed ...$args): mixed { - if ($this->object === null) { - throw new GraphQLRuntimeException('You must call "setObject" on MagicPropertyResolver before invoking the object.'); + if ($source === null) { + throw new GraphQLRuntimeException('You must provide a source for MagicPropertyResolver.'); } - if (! method_exists($this->object, '__get')) { - throw MissingMagicGetException::cannotFindMagicGet(get_class($this->object)); + + if (! method_exists($source, '__get')) { + throw MissingMagicGetException::cannotFindMagicGet($source::class); } - return $this->object->__get($this->propertyName); + return $source->__get($this->propertyName); } public function toString(): string { - $class = $this->getObject()::class; - return $class . '::__get(\'' . $this->propertyName . '\')'; + return $this->className . '::__get(\'' . $this->propertyName . '\')'; } } diff --git a/src/Middlewares/ResolverInterface.php b/src/Middlewares/ResolverInterface.php index 6ead37a6f6..6b025f9e15 100644 --- a/src/Middlewares/ResolverInterface.php +++ b/src/Middlewares/ResolverInterface.php @@ -5,15 +5,20 @@ namespace TheCodingMachine\GraphQLite\Middlewares; /** - * A class that represents a callable on an object. + * Resolves a field's value on a type. * * @internal */ interface ResolverInterface { - public function getObject(): object; - public function toString(): string; - public function __invoke(mixed ...$args): mixed; + /** + * Returns the object that the field will be resolved on. For example, when resolving + * the {@see ExtendedContactType::uppercaseName()} field, the source is a {@see Contact} + * object, but execution source will be an instance of {@see ExtendedContactType}. + */ + public function executionSource(object|null $source): object; + + public function __invoke(object|null $source, mixed ...$args): mixed; } diff --git a/src/Middlewares/SecurityFieldMiddleware.php b/src/Middlewares/SecurityFieldMiddleware.php index a6a9bde9d9..d0884bd418 100644 --- a/src/Middlewares/SecurityFieldMiddleware.php +++ b/src/Middlewares/SecurityFieldMiddleware.php @@ -62,7 +62,7 @@ public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandler if ($makeReturnTypeNullable) { $type = $type->getWrappedType(); assert($type instanceof OutputType); - $queryFieldDescriptor->setType($type); + $queryFieldDescriptor = $queryFieldDescriptor->withType($type); } } @@ -71,8 +71,8 @@ public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandler $parameters = $queryFieldDescriptor->getParameters(); - $queryFieldDescriptor->setResolver(function (...$args) use ($securityAnnotations, $resolver, $failWith, $parameters, $queryFieldDescriptor, $originalResolver) { - $variables = $this->getVariables($args, $parameters, $originalResolver); + $queryFieldDescriptor = $queryFieldDescriptor->withResolver(function (object|null $source, ...$args) use ($originalResolver, $securityAnnotations, $resolver, $failWith, $parameters, $queryFieldDescriptor) { + $variables = $this->getVariables($args, $parameters, $originalResolver->executionSource($source)); foreach ($securityAnnotations as $annotation) { try { @@ -93,7 +93,7 @@ public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandler } } - return $resolver(...$args); + return $resolver($source, ...$args); }); return $fieldHandler->handle($queryFieldDescriptor); @@ -105,14 +105,14 @@ public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandler * * @return array */ - private function getVariables(array $args, array $parameters, ResolverInterface $callable): array + private function getVariables(array $args, array $parameters, object $source): array { $variables = [ // If a user is not logged, we provide an empty user object to make usage easier 'user' => $this->authenticationService->getUser(), 'authorizationService' => $this->authorizationService, // Used by the is_granted expression language function. 'authenticationService' => $this->authenticationService, // Used by the is_logged expression language function. - 'this' => $callable->getObject(), + 'this' => $source, ]; $argsName = array_keys($parameters); diff --git a/src/Middlewares/SecurityInputFieldMiddleware.php b/src/Middlewares/SecurityInputFieldMiddleware.php index bc21fa57bf..d930a08f37 100644 --- a/src/Middlewares/SecurityInputFieldMiddleware.php +++ b/src/Middlewares/SecurityInputFieldMiddleware.php @@ -46,8 +46,8 @@ public function process(InputFieldDescriptor $inputFieldDescriptor, InputFieldHa $parameters = $inputFieldDescriptor->getParameters(); - $inputFieldDescriptor->setResolver(function (...$args) use ($securityAnnotations, $resolver, $parameters, $inputFieldDescriptor, $originalResolver) { - $variables = $this->getVariables($args, $parameters, $originalResolver); + $inputFieldDescriptor = $inputFieldDescriptor->withResolver(function (object|null $source, ...$args) use ($originalResolver, $securityAnnotations, $resolver, $parameters, $inputFieldDescriptor) { + $variables = $this->getVariables($args, $parameters, $originalResolver->executionSource($source)); foreach ($securityAnnotations as $annotation) { try { @@ -61,7 +61,7 @@ public function process(InputFieldDescriptor $inputFieldDescriptor, InputFieldHa } } - return $resolver(...$args); + return $resolver($source, ...$args); }); return $inputFieldHandler->handle($inputFieldDescriptor); @@ -73,14 +73,14 @@ public function process(InputFieldDescriptor $inputFieldDescriptor, InputFieldHa * * @return array */ - private function getVariables(array $args, array $parameters, ResolverInterface $callable): array + private function getVariables(array $args, array $parameters, object $source): array { $variables = [ // If a user is not logged, we provide an empty user object to make usage easier 'user' => $this->authenticationService->getUser(), 'authorizationService' => $this->authorizationService, // Used by the is_granted expression language function. 'authenticationService' => $this->authenticationService, // Used by the is_logged expression language function. - 'this' => $callable->getObject(), + 'this' => $source, ]; $argsName = array_keys($parameters); diff --git a/src/Middlewares/ServiceResolver.php b/src/Middlewares/ServiceResolver.php index 1f0c9f8c39..873ced5729 100644 --- a/src/Middlewares/ServiceResolver.php +++ b/src/Middlewares/ServiceResolver.php @@ -7,8 +7,7 @@ use function get_class; /** - * A class that represents a callable on an object. - * The object can be modified after class invocation. + * Resolves field by calling a callable. * * @internal */ @@ -23,12 +22,12 @@ public function __construct(callable $callable) $this->callable = $callable; } - public function getObject(): object + public function executionSource(object|null $source): object { return $this->callable[0]; } - public function __invoke(mixed ...$args): mixed + public function __invoke(object|null $source, mixed ...$args): mixed { $callable = $this->callable; @@ -37,7 +36,7 @@ public function __invoke(mixed ...$args): mixed public function toString(): string { - $class = get_class($this->getObject()); + $class = get_class($this->callable[0]); return $class . '::' . $this->callable[1] . '()'; } diff --git a/src/Middlewares/SourceInputPropertyResolver.php b/src/Middlewares/SourceInputPropertyResolver.php index 66b76ee459..a8405d52a5 100644 --- a/src/Middlewares/SourceInputPropertyResolver.php +++ b/src/Middlewares/SourceInputPropertyResolver.php @@ -7,48 +7,42 @@ use TheCodingMachine\GraphQLite\GraphQLRuntimeException; use TheCodingMachine\GraphQLite\Utils\PropertyAccessor; -use function assert; - /** - * A class that represents a callable on an object to resolve property value. - * The object can be modified after class invocation. + * Resolves field by setting the value of $propertyName on the source object. * * @internal */ -final class SourceInputPropertyResolver implements SourceResolverInterface +final class SourceInputPropertyResolver implements ResolverInterface { - private object|null $object = null; - - public function __construct(private readonly string $propertyName) - { - } - - public function setObject(object $object): void + public function __construct( + private readonly string $className, + private readonly string $propertyName, + ) { - $this->object = $object; } - public function getObject(): object + public function executionSource(object|null $source): object { - $object = $this->object; - assert($object !== null); + if ($source === null) { + throw new GraphQLRuntimeException('You must provide a source for SourceInputPropertyResolver.'); + } - return $object; + return $source; } - public function __invoke(mixed ...$args): mixed + public function __invoke(object|null $source, mixed ...$args): mixed { - if ($this->object === null) { - throw new GraphQLRuntimeException('You must call "setObject" on SourceResolver before invoking the object.'); + if ($source === null) { + throw new GraphQLRuntimeException('You must provide a source for SourceInputPropertyResolver.'); } - PropertyAccessor::setValue($this->object, $this->propertyName, ...$args); + + PropertyAccessor::setValue($source, $this->propertyName, ...$args); + return $args[0]; } public function toString(): string { - $class = $this->getObject()::class; - - return $class . '::' . $this->propertyName; + return $this->className . '::' . $this->propertyName; } } diff --git a/src/Middlewares/SourceMethodResolver.php b/src/Middlewares/SourceMethodResolver.php new file mode 100644 index 0000000000..2aedcfa61b --- /dev/null +++ b/src/Middlewares/SourceMethodResolver.php @@ -0,0 +1,51 @@ +methodName]; + assert(is_callable($callable)); + + return $callable(...$args); + } + + public function toString(): string + { + return $this->className . '::' . $this->methodName . '()'; + } +} diff --git a/src/Middlewares/SourcePropertyResolver.php b/src/Middlewares/SourcePropertyResolver.php index 29582ab0c1..72fa720aab 100644 --- a/src/Middlewares/SourcePropertyResolver.php +++ b/src/Middlewares/SourcePropertyResolver.php @@ -7,47 +7,40 @@ use TheCodingMachine\GraphQLite\GraphQLRuntimeException; use TheCodingMachine\GraphQLite\Utils\PropertyAccessor; -use function assert; - /** - * A class that represents a callable on an object to resolve property value. - * The object can be modified after class invocation. + * Resolves field by getting the value of $propertyName from the source object. * * @internal */ -final class SourcePropertyResolver implements SourceResolverInterface +final class SourcePropertyResolver implements ResolverInterface { - private object|null $object = null; - - public function __construct(private readonly string $propertyName) - { - } - - public function setObject(object $object): void + public function __construct( + private readonly string $className, + private readonly string $propertyName, + ) { - $this->object = $object; } - public function getObject(): object + public function executionSource(object|null $source): object { - $object = $this->object; - assert($object !== null); + if ($source === null) { + throw new GraphQLRuntimeException('You must provide a source for SourcePropertyResolver.'); + } - return $object; + return $source; } - public function __invoke(mixed ...$args): mixed + public function __invoke(object|null $source, mixed ...$args): mixed { - if ($this->object === null) { - throw new GraphQLRuntimeException('You must call "setObject" on SourceResolver before invoking the object.'); + if ($source === null) { + throw new GraphQLRuntimeException('You must provide a source for SourcePropertyResolver.'); } - return PropertyAccessor::getValue($this->object, $this->propertyName, ...$args); + return PropertyAccessor::getValue($source, $this->propertyName, ...$args); } public function toString(): string { - $class = $this->getObject()::class; - return $class . '::' . $this->propertyName; + return $this->className . '::' . $this->propertyName; } } diff --git a/src/Middlewares/SourceResolver.php b/src/Middlewares/SourceResolver.php deleted file mode 100644 index 8f4b6e5cc4..0000000000 --- a/src/Middlewares/SourceResolver.php +++ /dev/null @@ -1,53 +0,0 @@ -object = $object; - } - - public function getObject(): object - { - assert($this->object !== null); - return $this->object; - } - - public function __invoke(mixed ...$args): mixed - { - if ($this->object === null) { - throw new GraphQLRuntimeException('You must call "setObject" on SourceResolver before invoking the object.'); - } - $callable = [$this->object, $this->methodName]; - assert(is_callable($callable)); - - return $callable(...$args); - } - - public function toString(): string - { - $class = $this->getObject()::class; - return $class . '::' . $this->methodName . '()'; - } -} diff --git a/src/Middlewares/SourceResolverInterface.php b/src/Middlewares/SourceResolverInterface.php deleted file mode 100644 index 2ee9a62b8a..0000000000 --- a/src/Middlewares/SourceResolverInterface.php +++ /dev/null @@ -1,16 +0,0 @@ -doesHaveDefaultValue = $hasDefaultValue; } /** @param array $args */ @@ -27,7 +30,7 @@ public function resolve(object|null $source, array $args, mixed $context, Resolv return $this->argumentResolver->resolve($source, $args[$this->name], $context, $info, $this->type); } - if ($this->doesHaveDefaultValue) { + if ($this->hasDefaultValue) { return $this->defaultValue; } @@ -52,7 +55,7 @@ public function getType(): InputType&Type public function hasDefaultValue(): bool { - return $this->doesHaveDefaultValue; + return $this->hasDefaultValue; } public function getDefaultValue(): mixed @@ -64,9 +67,4 @@ public function getDescription(): string { return $this->description ?? ''; } - - public function setDescription(string $description): void - { - $this->description = $description; - } } diff --git a/src/Parameters/InputTypeProperty.php b/src/Parameters/InputTypeProperty.php index ee62a0245d..113059500b 100644 --- a/src/Parameters/InputTypeProperty.php +++ b/src/Parameters/InputTypeProperty.php @@ -10,9 +10,24 @@ class InputTypeProperty extends InputTypeParameter { - public function __construct(private readonly string $propertyName, string $fieldName, InputType&Type $type, bool $hasDefaultValue, mixed $defaultValue, ArgumentResolver $argumentResolver) + public function __construct( + private readonly string $propertyName, + string $fieldName, + InputType&Type $type, + string $description, + bool $hasDefaultValue, + mixed $defaultValue, + ArgumentResolver $argumentResolver, + ) { - parent::__construct($fieldName, $type, $hasDefaultValue, $defaultValue, $argumentResolver); + parent::__construct( + $fieldName, + $type, + $description, + $hasDefaultValue, + $defaultValue, + $argumentResolver, + ); } public function getPropertyName(): string diff --git a/src/Parameters/PrefetchDataParameter.php b/src/Parameters/PrefetchDataParameter.php index aa4acdf2db..76036fb0ab 100644 --- a/src/Parameters/PrefetchDataParameter.php +++ b/src/Parameters/PrefetchDataParameter.php @@ -5,23 +5,68 @@ namespace TheCodingMachine\GraphQLite\Parameters; use GraphQL\Type\Definition\ResolveInfo; +use TheCodingMachine\GraphQLite\Context\ContextInterface; +use TheCodingMachine\GraphQLite\GraphQLRuntimeException; +use TheCodingMachine\GraphQLite\Middlewares\ResolverInterface; +use TheCodingMachine\GraphQLite\PrefetchBuffer; +use TheCodingMachine\GraphQLite\QueryField; + +use function array_unshift; +use function assert; +use function is_callable; /** * Typically the first parameter of "self" fields or the second parameter of "external" fields that will be filled with the data fetched from the prefetch method. */ class PrefetchDataParameter implements ParameterInterface { - private mixed $prefetchedData; + /** @param array $parameters Indexed by argument name. */ + public function __construct( + private readonly string $fieldName, + private readonly ResolverInterface $originalResolver, + private readonly string $methodName, + private readonly array $parameters, + ) + { + } /** @param array $args */ public function resolve(object|null $source, array $args, mixed $context, ResolveInfo $info): mixed { - // Note: data cannot be known at build time. - return $this->prefetchedData; + // The PrefetchBuffer must be tied to the current request execution. The only object we have for this is $context + // $context MUST be a ContextInterface + if (! $context instanceof ContextInterface) { + throw new GraphQLRuntimeException('When using "prefetch", you sure ensure that the GraphQL execution "context" (passed to the GraphQL::executeQuery method) is an instance of \TheCodingMachine\GraphQLite\Context\Context'); + } + + $prefetchBuffer = $context->getPrefetchBuffer($this); + + if (! $prefetchBuffer->hasResult($args)) { + $prefetchResult = $this->computePrefetch($source, $args, $context, $info, $prefetchBuffer); + + $prefetchBuffer->storeResult($prefetchResult, $args); + } + + return $prefetchResult ?? $prefetchBuffer->getResult($args); } - public function setPrefetchedData(mixed $prefetchedData): void + /** @param array $args */ + private function computePrefetch(object|null $source, array $args, mixed $context, ResolveInfo $info, PrefetchBuffer $prefetchBuffer): mixed { - $this->prefetchedData = $prefetchedData; + // TODO: originalPrefetchResolver and prefetchResolver needed!!! + $prefetchCallable = [ + $this->originalResolver->executionSource($source), + $this->methodName, + ]; + + $sources = $prefetchBuffer->getObjectsByArguments($args); + + assert(is_callable($prefetchCallable)); + $toPassPrefetchArgs = QueryField::paramsToArguments($this->fieldName, $this->parameters, $source, $args, $context, $info, $prefetchCallable); + + array_unshift($toPassPrefetchArgs, $sources); + assert(is_callable($prefetchCallable)); + + return $prefetchCallable(...$toPassPrefetchArgs); } } diff --git a/src/PrefetchBuffer.php b/src/PrefetchBuffer.php index 5105d02249..70cc588af0 100644 --- a/src/PrefetchBuffer.php +++ b/src/PrefetchBuffer.php @@ -19,20 +19,20 @@ class PrefetchBuffer /** @var array An array of prefetch method results, indexed by hash of arguments. */ private array $results = []; - /** @param array $arguments The input arguments passed from GraphQL to the field. */ + /** @param array $arguments The input arguments passed from GraphQL to the field. */ public function register(object $object, array $arguments): void { $this->objects[$this->computeHash($arguments)][] = $object; } - /** @param array $arguments The input arguments passed from GraphQL to the field. */ + /** @param array $arguments The input arguments passed from GraphQL to the field. */ private function computeHash(array $arguments): string { return md5(serialize($arguments)); } /** - * @param array $arguments The input arguments passed from GraphQL to the field. + * @param array $arguments The input arguments passed from GraphQL to the field. * * @return array */ @@ -41,25 +41,25 @@ public function getObjectsByArguments(array $arguments): array return $this->objects[$this->computeHash($arguments)] ?? []; } - /** @param array $arguments The input arguments passed from GraphQL to the field. */ + /** @param array $arguments The input arguments passed from GraphQL to the field. */ public function purge(array $arguments): void { unset($this->objects[$this->computeHash($arguments)]); } - /** @param array $arguments The input arguments passed from GraphQL to the field. */ + /** @param array $arguments The input arguments passed from GraphQL to the field. */ public function storeResult(mixed $result, array $arguments): void { $this->results[$this->computeHash($arguments)] = $result; } - /** @param array $arguments The input arguments passed from GraphQL to the field. */ + /** @param array $arguments The input arguments passed from GraphQL to the field. */ public function hasResult(array $arguments): bool { return array_key_exists($this->computeHash($arguments), $this->results); } - /** @param array $arguments The input arguments passed from GraphQL to the field. */ + /** @param array $arguments The input arguments passed from GraphQL to the field. */ public function getResult(array $arguments): mixed { return $this->results[$this->computeHash($arguments)]; diff --git a/src/QueryField.php b/src/QueryField.php index e4be4cb841..69c8f569fb 100644 --- a/src/QueryField.php +++ b/src/QueryField.php @@ -16,16 +16,12 @@ use TheCodingMachine\GraphQLite\Context\ContextInterface; use TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException; use TheCodingMachine\GraphQLite\Middlewares\ResolverInterface; -use TheCodingMachine\GraphQLite\Middlewares\SourceResolverInterface; use TheCodingMachine\GraphQLite\Parameters\MissingArgumentException; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; use TheCodingMachine\GraphQLite\Parameters\PrefetchDataParameter; use TheCodingMachine\GraphQLite\Parameters\SourceParameter; -use function array_unshift; use function assert; -use function is_callable; -use function is_object; /** * A GraphQL field that maps to a PHP method automatically. @@ -46,7 +42,18 @@ final class QueryField extends FieldDefinition * @param array $prefetchArgs Indexed by argument name. * @param array{resolve?: FieldResolver|null,args?: ArgumentListConfig|null,description?: string|null,deprecationReason?: string|null,astNode?: FieldDefinitionNode|null,complexity?: ComplexityFn|null} $additionalConfig */ - public function __construct(string $name, OutputType $type, array $arguments, ResolverInterface $originalResolver, callable $resolver, string|null $comment, string|null $deprecationReason, string|null $prefetchMethodName, array $prefetchArgs, array $additionalConfig = []) + public function __construct( + string $name, + OutputType $type, + array $arguments, + ResolverInterface $originalResolver, + callable $resolver, + string|null $comment, + string|null $deprecationReason, + string|null $prefetchMethodName, + array $prefetchArgs, + array $additionalConfig = [], + ) { $config = [ 'name' => $name, @@ -60,10 +67,7 @@ public function __construct(string $name, OutputType $type, array $arguments, Re $config['deprecationReason'] = $deprecationReason; } - $resolveFn = function ($source, array $args, $context, ResolveInfo $info) use ($arguments, $originalResolver, $resolver) { - if ($originalResolver instanceof SourceResolverInterface) { - $originalResolver->setObject($source); - } + $resolveFn = function (object|null $source, array $args, $context, ResolveInfo $info) use ($name, $arguments, $originalResolver, $resolver) { /*if ($resolve !== null) { $method = $resolve; } elseif ($targetMethodOnSource !== null) { @@ -71,20 +75,15 @@ public function __construct(string $name, OutputType $type, array $arguments, Re } else { throw new InvalidArgumentException('The QueryField constructor should be passed either a resolve method or a target method on source object.'); }*/ + $toPassArgs = self::paramsToArguments($name, $arguments, $source, $args, $context, $info, $resolver); - $toPassArgs = $this->paramsToArguments($arguments, $source, $args, $context, $info, $resolver); - - $result = $resolver(...$toPassArgs); + $result = $resolver($source, ...$toPassArgs); try { $this->assertReturnType($result); } catch (TypeMismatchRuntimeException $e) { - $class = $originalResolver->getObject(); - if (is_object($class)) { - $class = $class::class; - } - $e->addInfo($this->name, $originalResolver->toString()); + throw $e; } @@ -94,48 +93,27 @@ public function __construct(string $name, OutputType $type, array $arguments, Re if ($prefetchMethodName === null) { $config['resolve'] = $resolveFn; } else { - $config['resolve'] = function ($source, array $args, $context, ResolveInfo $info) use ($arguments, $prefetchArgs, $prefetchMethodName, $resolveFn, $originalResolver) { + $config['resolve'] = static function ($source, array $args, $context, ResolveInfo $info) use ($arguments, $resolveFn) { // The PrefetchBuffer must be tied to the current request execution. The only object we have for this is $context // $context MUST be a ContextInterface - if (! $context instanceof ContextInterface) { throw new GraphQLRuntimeException('When using "prefetch", you sure ensure that the GraphQL execution "context" (passed to the GraphQL::executeQuery method) is an instance of \TheCodingMachine\GraphQLite\Context\Context'); } - $prefetchBuffer = $context->getPrefetchBuffer($this); - - $prefetchBuffer->register($source, $args); + // TODO: this is to be refactored in a prefetch refactor PR that follows. For now this hack will do. + foreach ($arguments as $argument) { + if ($argument instanceof PrefetchDataParameter) { + $prefetchArgument = $argument; - return new Deferred(function () use ($prefetchBuffer, $source, $args, $context, $info, $prefetchArgs, $prefetchMethodName, $arguments, $resolveFn, $originalResolver) { - if (! $prefetchBuffer->hasResult($args)) { - if ($originalResolver instanceof SourceResolverInterface) { - $originalResolver->setObject($source); - } - - // TODO: originalPrefetchResolver and prefetchResolver needed!!! - $prefetchCallable = [$originalResolver->getObject(), $prefetchMethodName]; - - $sources = $prefetchBuffer->getObjectsByArguments($args); - - assert(is_callable($prefetchCallable)); - $toPassPrefetchArgs = $this->paramsToArguments($prefetchArgs, $source, $args, $context, $info, $prefetchCallable); - - array_unshift($toPassPrefetchArgs, $sources); - assert(is_callable($prefetchCallable)); - $prefetchResult = $prefetchCallable(...$toPassPrefetchArgs); - $prefetchBuffer->storeResult($prefetchResult, $args); - } else { - $prefetchResult = $prefetchBuffer->getResult($args); + break; } + } - foreach ($arguments as $argument) { - if (! ($argument instanceof PrefetchDataParameter)) { - continue; - } + assert(($prefetchArgument ?? null) !== null); - $argument->setPrefetchedData($prefetchResult); - } + $context->getPrefetchBuffer($prefetchArgument)->register($source, $args); + return new Deferred(static function () use ($source, $args, $context, $info, $resolveFn) { return $resolveFn($source, $args, $context, $info); }); }; @@ -181,18 +159,16 @@ public static function alwaysReturn(QueryFieldDescriptor $fieldDescriptor, mixed return $value; }; - $fieldDescriptor->setResolver($callable); + $fieldDescriptor = $fieldDescriptor->withResolver($callable); return self::fromDescriptor($fieldDescriptor); } private static function fromDescriptor(QueryFieldDescriptor $fieldDescriptor): self { - $type = $fieldDescriptor->getType(); - assert($type !== null); return new self( $fieldDescriptor->getName(), - $type, + $fieldDescriptor->getType(), $fieldDescriptor->getParameters(), $fieldDescriptor->getOriginalResolver(), $fieldDescriptor->getResolver(), @@ -207,12 +183,19 @@ public static function fromFieldDescriptor(QueryFieldDescriptor $fieldDescriptor { $arguments = $fieldDescriptor->getParameters(); if ($fieldDescriptor->getPrefetchMethodName() !== null) { - $arguments = ['__graphqlite_prefectData' => new PrefetchDataParameter()] + $arguments; + $arguments = [ + '__graphqlite_prefectData' => new PrefetchDataParameter( + fieldName: $fieldDescriptor->getName(), + originalResolver: $fieldDescriptor->getOriginalResolver(), + methodName: $fieldDescriptor->getPrefetchMethodName(), + parameters: $fieldDescriptor->getPrefetchParameters(), + ), + ] + $arguments; } if ($fieldDescriptor->isInjectSource() === true) { $arguments = ['__graphqlite_source' => new SourceParameter()] + $arguments; } - $fieldDescriptor->setParameters($arguments); + $fieldDescriptor = $fieldDescriptor->withParameters($arguments); return self::fromDescriptor($fieldDescriptor); } @@ -225,7 +208,7 @@ public static function fromFieldDescriptor(QueryFieldDescriptor $fieldDescriptor * * @return array */ - private function paramsToArguments(array $parameters, object|null $source, array $args, mixed $context, ResolveInfo $info, callable $resolve): array + public static function paramsToArguments(string $name, array $parameters, object|null $source, array $args, mixed $context, ResolveInfo $info, callable $resolve): array { $toPassArgs = []; $exceptions = []; @@ -233,7 +216,7 @@ private function paramsToArguments(array $parameters, object|null $source, array try { $toPassArgs[] = $parameter->resolve($source, $args, $context, $info); } catch (MissingArgumentException $e) { - throw MissingArgumentException::wrapWithFieldContext($e, $this->name, $resolve); + throw MissingArgumentException::wrapWithFieldContext($e, $name, $resolve); } catch (ClientAware $e) { $exceptions[] = $e; } diff --git a/src/QueryFieldDescriptor.php b/src/QueryFieldDescriptor.php index 52252dab25..7698651cba 100644 --- a/src/QueryFieldDescriptor.php +++ b/src/QueryFieldDescriptor.php @@ -12,10 +12,12 @@ use TheCodingMachine\GraphQLite\Middlewares\MagicPropertyResolver; use TheCodingMachine\GraphQLite\Middlewares\ResolverInterface; use TheCodingMachine\GraphQLite\Middlewares\ServiceResolver; +use TheCodingMachine\GraphQLite\Middlewares\SourceMethodResolver; use TheCodingMachine\GraphQLite\Middlewares\SourcePropertyResolver; -use TheCodingMachine\GraphQLite\Middlewares\SourceResolver; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; +use TheCodingMachine\GraphQLite\Utils\Cloneable; +use function assert; use function is_array; /** @@ -25,51 +27,57 @@ */ class QueryFieldDescriptor { - private string $name; - /** @var (OutputType&Type)|null */ - private Type|null $type = null; - /** @var array */ - private array $parameters = []; - /** @var array */ - private array $prefetchParameters = []; - private string|null $prefetchMethodName = null; - /** @var callable|null */ - private $callable; - private string|null $targetMethodOnSource = null; - private string|null $targetPropertyOnSource = null; - private string|null $magicProperty = null; + use Cloneable; + + private readonly ResolverInterface $originalResolver; + /** @var callable */ + private readonly mixed $resolver; + /** - * Whether we should inject the source as the first parameter or not. + * @param array $parameters + * @param array $prefetchParameters + * @param callable $callable + * @param bool $injectSource Whether we should inject the source as the first parameter or not. */ - private bool $injectSource; - private string|null $comment = null; - private string|null $deprecationReason = null; - private MiddlewareAnnotations $middlewareAnnotations; - private ReflectionMethod $refMethod; - private ReflectionProperty $refProperty; - private ResolverInterface|null $originalResolver = null; - /** @var callable */ - private $resolver; + public function __construct( + private readonly string $name, + private readonly OutputType&Type $type, + private readonly array $parameters = [], + private readonly array $prefetchParameters = [], + private readonly string|null $prefetchMethodName = null, + private readonly mixed $callable = null, + private readonly string|null $targetClass = null, + private readonly string|null $targetMethodOnSource = null, + private readonly string|null $targetPropertyOnSource = null, + private readonly string|null $magicProperty = null, + private readonly bool $injectSource = false, + private readonly string|null $comment = null, + private readonly string|null $deprecationReason = null, + private readonly MiddlewareAnnotations $middlewareAnnotations = new MiddlewareAnnotations([]), + private readonly ReflectionMethod|null $refMethod = null, + private readonly ReflectionProperty|null $refProperty = null, + ) + { + } public function getName(): string { return $this->name; } - public function setName(string $name): void + public function withName(string $name): self { - $this->name = $name; + return $this->with(name: $name); } - /** @return (OutputType&Type)|null */ - public function getType(): Type|null + public function getType(): OutputType&Type { return $this->type; } - public function setType(OutputType&Type $type): void + public function withType(OutputType&Type $type): self { - $this->type = $type; + return $this->with(type: $type); } /** @return array */ @@ -79,9 +87,9 @@ public function getParameters(): array } /** @param array $parameters */ - public function setParameters(array $parameters): void + public function withParameters(array $parameters): self { - $this->parameters = $parameters; + return $this->with(parameters: $parameters); } /** @return array */ @@ -91,9 +99,9 @@ public function getPrefetchParameters(): array } /** @param array $prefetchParameters */ - public function setPrefetchParameters(array $prefetchParameters): void + public function withPrefetchParameters(array $prefetchParameters): self { - $this->prefetchParameters = $prefetchParameters; + return $this->with(prefetchParameters: $prefetchParameters); } public function getPrefetchMethodName(): string|null @@ -101,9 +109,9 @@ public function getPrefetchMethodName(): string|null return $this->prefetchMethodName; } - public function setPrefetchMethodName(string|null $prefetchMethodName): void + public function withPrefetchMethodName(string|null $prefetchMethodName): self { - $this->prefetchMethodName = $prefetchMethodName; + return $this->with(prefetchMethodName: $prefetchMethodName); } /** @@ -111,48 +119,64 @@ public function setPrefetchMethodName(string|null $prefetchMethodName): void * This should not be used in the context of a field middleware. * Use getResolver/setResolver if you want to wrap the resolver in another method. */ - public function setCallable(callable $callable): void + public function withCallable(callable $callable): self { - if ($this->originalResolver !== null) { - throw new GraphQLRuntimeException('You cannot modify the callable via setCallable because it was already used. You can still wrap the callable using getResolver/setResolver'); + if (isset($this->originalResolver)) { + throw new GraphQLRuntimeException('You cannot modify the callable via withCallable because it was already used. You can still wrap the callable using getResolver/withResolver'); } - $this->callable = $callable; - $this->targetMethodOnSource = null; - $this->targetPropertyOnSource = null; - $this->magicProperty = null; + + return $this->with( + callable: $callable, + targetClass: null, + targetMethodOnSource: null, + targetPropertyOnSource: null, + magicProperty: null, + ); } - public function setTargetMethodOnSource(string $targetMethodOnSource): void + public function withTargetMethodOnSource(string $className, string $targetMethodOnSource): self { - if ($this->originalResolver !== null) { - throw new GraphQLRuntimeException('You cannot modify the target method via setTargetMethodOnSource because it was already used. You can still wrap the callable using getResolver/setResolver'); + if (isset($this->originalResolver)) { + throw new GraphQLRuntimeException('You cannot modify the target method via withTargetMethodOnSource because it was already used. You can still wrap the callable using getResolver/withResolver'); } - $this->callable = null; - $this->targetMethodOnSource = $targetMethodOnSource; - $this->targetPropertyOnSource = null; - $this->magicProperty = null; + + return $this->with( + callable: null, + targetClass: $className, + targetMethodOnSource: $targetMethodOnSource, + targetPropertyOnSource: null, + magicProperty: null, + ); } - public function setTargetPropertyOnSource(string|null $targetPropertyOnSource): void + public function withTargetPropertyOnSource(string $className, string|null $targetPropertyOnSource): self { - if ($this->originalResolver !== null) { - throw new GraphQLRuntimeException('You cannot modify the target method via setTargetMethodOnSource because it was already used. You can still wrap the callable using getResolver/setResolver'); + if (isset($this->originalResolver)) { + throw new GraphQLRuntimeException('You cannot modify the target method via withTargetMethodOnSource because it was already used. You can still wrap the callable using getResolver/withResolver'); } - $this->callable = null; - $this->targetMethodOnSource = null; - $this->targetPropertyOnSource = $targetPropertyOnSource; - $this->magicProperty = null; + + return $this->with( + callable: null, + targetClass: $className, + targetMethodOnSource: null, + targetPropertyOnSource: $targetPropertyOnSource, + magicProperty: null, + ); } - public function setMagicProperty(string $magicProperty): void + public function withMagicProperty(string $className, string $magicProperty): self { - if ($this->originalResolver !== null) { - throw new GraphQLRuntimeException('You cannot modify the target method via setMagicProperty because it was already used. You can still wrap the callable using getResolver/setResolver'); + if (isset($this->originalResolver)) { + throw new GraphQLRuntimeException('You cannot modify the target method via withMagicProperty because it was already used. You can still wrap the callable using getResolver/withResolver'); } - $this->callable = null; - $this->targetMethodOnSource = null; - $this->targetPropertyOnSource = null; - $this->magicProperty = $magicProperty; + + return $this->with( + callable: null, + targetClass: $className, + targetMethodOnSource: null, + targetPropertyOnSource: null, + magicProperty: $magicProperty, + ); } public function isInjectSource(): bool @@ -160,9 +184,9 @@ public function isInjectSource(): bool return $this->injectSource; } - public function setInjectSource(bool $injectSource): void + public function withInjectSource(bool $injectSource): self { - $this->injectSource = $injectSource; + return $this->with(injectSource: $injectSource); } public function getComment(): string|null @@ -170,9 +194,9 @@ public function getComment(): string|null return $this->comment; } - public function setComment(string|null $comment): void + public function withComment(string|null $comment): self { - $this->comment = $comment; + return $this->with(comment: $comment); } public function getDeprecationReason(): string|null @@ -180,9 +204,9 @@ public function getDeprecationReason(): string|null return $this->deprecationReason; } - public function setDeprecationReason(string|null $deprecationReason): void + public function withDeprecationReason(string|null $deprecationReason): self { - $this->deprecationReason = $deprecationReason; + return $this->with(deprecationReason: $deprecationReason); } public function getMiddlewareAnnotations(): MiddlewareAnnotations @@ -190,29 +214,29 @@ public function getMiddlewareAnnotations(): MiddlewareAnnotations return $this->middlewareAnnotations; } - public function setMiddlewareAnnotations(MiddlewareAnnotations $middlewareAnnotations): void + public function withMiddlewareAnnotations(MiddlewareAnnotations $middlewareAnnotations): self { - $this->middlewareAnnotations = $middlewareAnnotations; + return $this->with(middlewareAnnotations: $middlewareAnnotations); } - public function getRefMethod(): ReflectionMethod + public function getRefMethod(): ReflectionMethod|null { return $this->refMethod; } - public function setRefMethod(ReflectionMethod $refMethod): void + public function withRefMethod(ReflectionMethod $refMethod): self { - $this->refMethod = $refMethod; + return $this->with(refMethod: $refMethod); } - public function getRefProperty(): ReflectionProperty + public function getRefProperty(): ReflectionProperty|null { return $this->refProperty; } - public function setRefProperty(ReflectionProperty $refProperty): void + public function withRefProperty(ReflectionProperty $refProperty): self { - $this->refProperty = $refProperty; + return $this->with(refProperty: $refProperty); } /** @@ -229,13 +253,19 @@ public function getOriginalResolver(): ResolverInterface $callable = $this->callable; $this->originalResolver = new ServiceResolver($callable); } elseif ($this->targetMethodOnSource !== null) { - $this->originalResolver = new SourceResolver($this->targetMethodOnSource); + assert($this->targetClass !== null); + + $this->originalResolver = new SourceMethodResolver($this->targetClass, $this->targetMethodOnSource); } elseif ($this->targetPropertyOnSource !== null) { - $this->originalResolver = new SourcePropertyResolver($this->targetPropertyOnSource); + assert($this->targetClass !== null); + + $this->originalResolver = new SourcePropertyResolver($this->targetClass, $this->targetPropertyOnSource); } elseif ($this->magicProperty !== null) { - $this->originalResolver = new MagicPropertyResolver($this->magicProperty); + assert($this->targetClass !== null); + + $this->originalResolver = new MagicPropertyResolver($this->targetClass, $this->magicProperty); } else { - throw new GraphQLRuntimeException('The QueryFieldDescriptor should be passed either a resolve method (via setCallable) or a target method on source object (via setTargetMethodOnSource) or a magic property (via setMagicProperty).'); + throw new GraphQLRuntimeException('The QueryFieldDescriptor should be passed either a resolve method (via withCallable) or a target method on source object (via withTargetMethodOnSource) or a magic property (via withMagicProperty).'); } return $this->originalResolver; @@ -247,15 +277,15 @@ public function getOriginalResolver(): ResolverInterface */ public function getResolver(): callable { - if ($this->resolver === null) { + if (! isset($this->resolver)) { $this->resolver = $this->getOriginalResolver(); } return $this->resolver; } - public function setResolver(callable $resolver): void + public function withResolver(callable $resolver): self { - $this->resolver = $resolver; + return $this->with(resolver: $resolver); } } diff --git a/src/Utils/Cloneable.php b/src/Utils/Cloneable.php new file mode 100644 index 0000000000..60fd21e485 --- /dev/null +++ b/src/Utils/Cloneable.php @@ -0,0 +1,45 @@ + ...$values + * + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint + */ + public function with(...$values): static + { + $refClass = new ReflectionClass(static::class); + $clone = $refClass->newInstanceWithoutConstructor(); + + foreach ($refClass->getProperties() as $refProperty) { + if ($refProperty->isStatic()) { + continue; + } + + $objectField = $refProperty->getName(); + + if (! array_key_exists($objectField, $values) && ! $refProperty->isInitialized($this)) { + continue; + } + + $objectValue = array_key_exists($objectField, $values) ? $values[$objectField] : $refProperty->getValue($this); + + $refProperty->setValue($clone, $objectValue); + } + + return $clone; + } +} diff --git a/tests/Fixtures/TestControllerWithArrayParam.php b/tests/Fixtures/TestControllerWithArrayParam.php index eed0990612..587f7636e8 100644 --- a/tests/Fixtures/TestControllerWithArrayParam.php +++ b/tests/Fixtures/TestControllerWithArrayParam.php @@ -10,6 +10,8 @@ class TestControllerWithArrayParam { /** * @Query() + * + * @return string[] */ public function test(iterable $params): array { diff --git a/tests/Mappers/RecursiveTypeMapperTest.php b/tests/Mappers/RecursiveTypeMapperTest.php index 4d1af1acc7..cf474a64d8 100644 --- a/tests/Mappers/RecursiveTypeMapperTest.php +++ b/tests/Mappers/RecursiveTypeMapperTest.php @@ -35,10 +35,11 @@ public function testMapClassToType(): void 'name' => 'Foobar' ]); - $typeMapper = new StaticTypeMapper(); - $typeMapper->setTypes([ - ClassB::class => $objectType - ]); + $typeMapper = new StaticTypeMapper( + types: [ + ClassB::class => $objectType + ] + ); $recursiveTypeMapper = new RecursiveTypeMapper( $typeMapper, @@ -63,10 +64,11 @@ public function testMapNameToType(): void 'name' => 'Foobar' ]); - $typeMapper = new StaticTypeMapper(); - $typeMapper->setTypes([ - ClassB::class => $objectType - ]); + $typeMapper = new StaticTypeMapper( + types: [ + ClassB::class => $objectType + ] + ); $recursiveTypeMapper = new RecursiveTypeMapper( $typeMapper, @@ -102,10 +104,11 @@ public function testMapClassToInputType(): void 'name' => 'Foobar' ]); - $typeMapper = new StaticTypeMapper(); - $typeMapper->setInputTypes([ - ClassB::class => $inputObjectType - ]); + $typeMapper = new StaticTypeMapper( + inputTypes: [ + ClassB::class => $inputObjectType + ] + ); $recursiveTypeMapper = new RecursiveTypeMapper( $typeMapper, @@ -199,15 +202,17 @@ public function testDuplicateDetection(): void 'name' => 'Foobar' ]); - $typeMapper1 = new StaticTypeMapper(); - $typeMapper1->setTypes([ - ClassB::class => $objectType - ]); + $typeMapper1 = new StaticTypeMapper( + types: [ + ClassB::class => $objectType + ] + ); - $typeMapper2 = new StaticTypeMapper(); - $typeMapper2->setTypes([ - ClassA::class => $objectType - ]); + $typeMapper2 = new StaticTypeMapper( + types: [ + ClassA::class => $objectType + ] + ); $compositeTypeMapper = new CompositeTypeMapper(); $compositeTypeMapper->addTypeMapper($typeMapper1); diff --git a/tests/Mappers/StaticTypeMapperTest.php b/tests/Mappers/StaticTypeMapperTest.php index 6e9971a52d..b340d83c20 100644 --- a/tests/Mappers/StaticTypeMapperTest.php +++ b/tests/Mappers/StaticTypeMapperTest.php @@ -31,37 +31,38 @@ class StaticTypeMapperTest extends AbstractQueryProviderTest public function setUp(): void { - $this->typeMapper = new StaticTypeMapper(); - $this->typeMapper->setTypes([ - TestObject::class => new MutableObjectType([ - 'name' => 'TestObject', - 'fields' => [ - 'test' => Type::string(), - ], - ]), - TestObject2::class => new ObjectType([ - 'name' => 'TestObject2', - 'fields' => [ - 'test' => Type::string(), - ], - ]) - ]); - $this->typeMapper->setInputTypes([ - TestObject::class => new MockResolvableInputObjectType([ - 'name' => 'TestInputObject', - 'fields' => [ - 'test' => Type::string(), - ], - ]) - ]); - $this->typeMapper->setNotMappedTypes([ - new ObjectType([ - 'name' => 'TestNotMappedObject', - 'fields' => [ - 'test' => Type::string(), - ] - ]) - ]); + $this->typeMapper = new StaticTypeMapper( + types: [ + TestObject::class => new MutableObjectType([ + 'name' => 'TestObject', + 'fields' => [ + 'test' => Type::string(), + ], + ]), + TestObject2::class => new ObjectType([ + 'name' => 'TestObject2', + 'fields' => [ + 'test' => Type::string(), + ], + ]) + ], + inputTypes: [ + TestObject::class => new MockResolvableInputObjectType([ + 'name' => 'TestInputObject', + 'fields' => [ + 'test' => Type::string(), + ], + ]) + ], + notMappedTypes: [ + new ObjectType([ + 'name' => 'TestNotMappedObject', + 'fields' => [ + 'test' => Type::string(), + ] + ]) + ] + ); } public function testStaticTypeMapper(): void @@ -138,25 +139,25 @@ public function testEndToEnd(): void $schemaFactory = new SchemaFactory(new Psr16Cache($arrayAdapter), new BasicAutoWiringContainer(new EmptyContainer())); $schemaFactory->addControllerNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\StaticTypeMapper\\Controllers'); - $staticTypeMapper = new StaticTypeMapper(); // Let's register a type that maps by default to the "MyClass" PHP class - $staticTypeMapper->setTypes([ - TestLegacyObject::class => new ObjectType([ - 'name' => 'TestLegacyObject', - 'fields' => [ - 'foo' => [ - 'type' =>Type::int(), - 'resolve' => function(TestLegacyObject $source) { - return $source->getFoo(); - } + $staticTypeMapper = new StaticTypeMapper( + types: [ + TestLegacyObject::class => new ObjectType([ + 'name' => 'TestLegacyObject', + 'fields' => [ + 'foo' => [ + 'type' =>Type::int(), + 'resolve' => function(TestLegacyObject $source) { + return $source->getFoo(); + } + ] ] - ] - ]), - ]); - - $staticTypeMapper->setNotMappedTypes([ - new InterfaceType(['name' => 'FooInterface']) - ]); + ]), + ], + notMappedTypes: [ + new InterfaceType(['name' => 'FooInterface']) + ] + ); // Register the static type mapper in your application using the SchemaFactory instance $schemaFactory->addTypeMapper($staticTypeMapper); diff --git a/tests/Middlewares/AuthorizationFieldMiddlewareTest.php b/tests/Middlewares/AuthorizationFieldMiddlewareTest.php index 577da826ec..c596c0399e 100644 --- a/tests/Middlewares/AuthorizationFieldMiddlewareTest.php +++ b/tests/Middlewares/AuthorizationFieldMiddlewareTest.php @@ -3,6 +3,7 @@ namespace TheCodingMachine\GraphQLite\Middlewares; use GraphQL\Type\Definition\FieldDefinition; +use GraphQL\Type\Definition\Type; use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; use TheCodingMachine\GraphQLite\Annotations\Exceptions\IncompatibleAnnotationsException; use TheCodingMachine\GraphQLite\Annotations\FailWith; @@ -29,8 +30,9 @@ public function testReturnsResolversValueWhenAuthorized(): void ->willReturn(true); $middleware = new AuthorizationFieldMiddleware($authenticationService, $authorizationService); - $descriptor = $this->stubDescriptor([new Logged(), new Right('test')]); - $descriptor->setResolver(fn () => 123); + $descriptor = $this + ->stubDescriptor([new Logged(), new Right('test')]) + ->withResolver(fn () => 123); $field = $middleware->process($descriptor, $this->stubFieldHandler()); @@ -100,9 +102,12 @@ public function testThrowsForbiddenExceptionWhenNotAuthorized(): void */ private function stubDescriptor(array $annotations): QueryFieldDescriptor { - $descriptor = new QueryFieldDescriptor(); - $descriptor->setMiddlewareAnnotations(new MiddlewareAnnotations($annotations)); - $descriptor->setResolver(fn () => self::fail('Should not be called.')); + $descriptor = new QueryFieldDescriptor( + name: 'foo', + type: Type::string(), + middlewareAnnotations: new MiddlewareAnnotations($annotations), + ); + $descriptor = $descriptor->withResolver(fn () => self::fail('Should not be called.')); return $descriptor; } @@ -113,7 +118,7 @@ private function stubFieldHandler(): FieldHandlerInterface public function handle(QueryFieldDescriptor $fieldDescriptor): FieldDefinition|null { return new FieldDefinition([ - 'name' => 'foo', + 'name' => $fieldDescriptor->getName(), 'resolve' => $fieldDescriptor->getResolver(), ]); } diff --git a/tests/Middlewares/AuthorizationInputFieldMiddlewareTest.php b/tests/Middlewares/AuthorizationInputFieldMiddlewareTest.php index 9cfedf5b9a..db299e99e6 100644 --- a/tests/Middlewares/AuthorizationInputFieldMiddlewareTest.php +++ b/tests/Middlewares/AuthorizationInputFieldMiddlewareTest.php @@ -31,8 +31,9 @@ public function testReturnsResolversValueWhenAuthorized(): void ->willReturn(true); $middleware = new AuthorizationInputFieldMiddleware($authenticationService, $authorizationService); - $descriptor = $this->stubDescriptor([new Logged(), new Right('test')]); - $descriptor->setResolver(fn () => 123); + $descriptor = $this + ->stubDescriptor([new Logged(), new Right('test')]) + ->withResolver(fn () => 123); $field = $middleware->process($descriptor, $this->stubFieldHandler()); @@ -83,10 +84,14 @@ public function testThrowsForbiddenExceptionWhenNotAuthorized(): void */ private function stubDescriptor(array $annotations): InputFieldDescriptor { - $descriptor = new InputFieldDescriptor(); - $descriptor->setMiddlewareAnnotations(new MiddlewareAnnotations($annotations)); - $descriptor->setTargetMethodOnSource('foo'); - $descriptor->setResolver(fn () => self::fail('Should not be called.')); + $descriptor = new InputFieldDescriptor( + name: 'foo', + type: Type::string(), + targetClass: stdClass::class, + targetMethodOnSource: 'foo', + middlewareAnnotations: new MiddlewareAnnotations($annotations), + ); + $descriptor = $descriptor->withResolver(fn () => self::fail('Should not be called.')); return $descriptor; } @@ -97,8 +102,8 @@ private function stubFieldHandler(): InputFieldHandlerInterface public function handle(InputFieldDescriptor $inputFieldDescriptor): InputField|null { return new InputField( - name: 'foo', - type: Type::string(), + name: $inputFieldDescriptor->getName(), + type: $inputFieldDescriptor->getType(), arguments: [ 'foo' => new SourceParameter(), ], diff --git a/tests/Middlewares/FieldMiddlewarePipeTest.php b/tests/Middlewares/FieldMiddlewarePipeTest.php index 3a4ee7a130..8fd3815920 100644 --- a/tests/Middlewares/FieldMiddlewarePipeTest.php +++ b/tests/Middlewares/FieldMiddlewarePipeTest.php @@ -19,9 +19,13 @@ public function handle(QueryFieldDescriptor $fieldDescriptor): ?FieldDefinition } }; + $descriptor = new QueryFieldDescriptor( + name: 'foo', + type: Type::string(), + ); $middlewarePipe = new FieldMiddlewarePipe(); - $definition = $middlewarePipe->process(new QueryFieldDescriptor(), $finalHandler); + $definition = $middlewarePipe->process($descriptor, $finalHandler); $this->assertSame('foo', $definition->name); $middlewarePipe->pipe(new class implements FieldMiddlewareInterface { @@ -31,7 +35,12 @@ public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandler } }); - $definition = $middlewarePipe->process(new QueryFieldDescriptor(), $finalHandler); + $descriptor = new QueryFieldDescriptor( + name: 'bar', + type: Type::string(), + ); + + $definition = $middlewarePipe->process($descriptor, $finalHandler); $this->assertSame('bar', $definition->name); } } diff --git a/tests/Middlewares/InputFieldMiddlewarePipeTest.php b/tests/Middlewares/InputFieldMiddlewarePipeTest.php index 37ef5385c2..5454d5ec8d 100644 --- a/tests/Middlewares/InputFieldMiddlewarePipeTest.php +++ b/tests/Middlewares/InputFieldMiddlewarePipeTest.php @@ -20,20 +20,22 @@ public function handle(InputFieldDescriptor $inputFieldDescriptor): ?InputField }; $middlewarePipe = new InputFieldMiddlewarePipe(); - $inputFieldDescriptor = new InputFieldDescriptor(); - $inputFieldDescriptor->setCallable(static function (){ - return null; - }); - $inputFieldDescriptor->setName("foo"); - $inputFieldDescriptor->setType(Type::string()); + $inputFieldDescriptor = new InputFieldDescriptor( + name: 'foo', + type: Type::string(), + callable: static function (){ + return null; + } + ); $definition = $middlewarePipe->process($inputFieldDescriptor, $finalHandler); $this->assertSame('foo', $definition->name); $middlewarePipe->pipe(new class implements InputFieldMiddlewareInterface { public function process(InputFieldDescriptor $inputFieldDescriptor, InputFieldHandlerInterface $inputFieldHandler): ?InputField { - $inputFieldDescriptor->setName("bar"); - return InputField::fromFieldDescriptor($inputFieldDescriptor); + return InputField::fromFieldDescriptor( + $inputFieldDescriptor->withName("bar") + ); } }); diff --git a/tests/Middlewares/MagicPropertyResolverTest.php b/tests/Middlewares/MagicPropertyResolverTest.php index 3179c764de..e25b421b50 100644 --- a/tests/Middlewares/MagicPropertyResolverTest.php +++ b/tests/Middlewares/MagicPropertyResolverTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use stdClass; +use TheCodingMachine\GraphQLite\Fixtures\TestTypeWithMagicProperty; use TheCodingMachine\GraphQLite\GraphQLRuntimeException; class MagicPropertyResolverTest extends TestCase @@ -11,23 +12,23 @@ class MagicPropertyResolverTest extends TestCase public function testExceptionInInvoke() { - $sourceResolver = new MagicPropertyResolver('test'); + $sourceResolver = new MagicPropertyResolver(stdClass::class, 'test'); $this->expectException(GraphQLRuntimeException::class); - $sourceResolver(); + + $sourceResolver(null); } public function testToString() { - $sourceResolver = new MagicPropertyResolver('test'); - $sourceResolver->setObject(new stdClass()); + $sourceResolver = new MagicPropertyResolver(stdClass::class, 'test'); + $this->assertSame("stdClass::__get('test')", $sourceResolver->toString()); } - public function testGetObject() + public function testInvoke() { - $sourceResolver = new MagicPropertyResolver('test'); - $obj = new stdClass(); - $sourceResolver->setObject($obj); - $this->assertSame($obj, $sourceResolver->getObject()); + $sourceResolver = new MagicPropertyResolver(TestTypeWithMagicProperty::class, 'test'); + + $this->assertSame('foo', $sourceResolver(new TestTypeWithMagicProperty())); } } diff --git a/tests/Middlewares/SourceResolverTest.php b/tests/Middlewares/SourceResolverTest.php index 6f817e583a..09e1768b3d 100644 --- a/tests/Middlewares/SourceResolverTest.php +++ b/tests/Middlewares/SourceResolverTest.php @@ -11,15 +11,15 @@ class SourceResolverTest extends TestCase public function testExceptionInInvoke() { - $sourceResolver = new SourceResolver('test'); + $sourceResolver = new SourceMethodResolver(stdClass::class, 'test'); $this->expectException(GraphQLRuntimeException::class); - $sourceResolver(); + $sourceResolver(null); } public function testToString() { - $sourceResolver = new SourceResolver('test'); - $sourceResolver->setObject(new stdClass()); + $sourceResolver = new SourceMethodResolver(stdClass::class, 'test'); + $this->assertSame('stdClass::test()', $sourceResolver->toString()); } } diff --git a/tests/QueryFieldDescriptorTest.php b/tests/QueryFieldDescriptorTest.php index 796487406a..7e623df62f 100644 --- a/tests/QueryFieldDescriptorTest.php +++ b/tests/QueryFieldDescriptorTest.php @@ -2,53 +2,70 @@ namespace TheCodingMachine\GraphQLite; +use GraphQL\Type\Definition\Type; use PHPUnit\Framework\TestCase; +use stdClass; class QueryFieldDescriptorTest extends TestCase { public function testExceptionInSetCallable(): void { - $descriptor = new QueryFieldDescriptor(); - $descriptor->setCallable([$this, 'testExceptionInSetCallable']); + $descriptor = new QueryFieldDescriptor( + name: 'test', + type: Type::string(), + callable: [$this, 'testExceptionInSetCallable'], + ); $descriptor->getResolver(); $this->expectException(GraphQLRuntimeException::class); - $descriptor->setCallable([$this, 'testExceptionInSetCallable']); + $descriptor->withCallable([$this, 'testExceptionInSetCallable']); } public function testExceptionInSetTargetMethodOnSource(): void { - $descriptor = new QueryFieldDescriptor(); - $descriptor->setTargetMethodOnSource('test'); + $descriptor = new QueryFieldDescriptor( + name: 'test', + type: Type::string(), + targetClass: stdClass::class, + targetMethodOnSource: 'test' + ); $descriptor->getResolver(); $this->expectException(GraphQLRuntimeException::class); - $descriptor->setTargetMethodOnSource('test'); + $descriptor->withTargetMethodOnSource(stdClass::class, 'test'); } public function testExceptionInSetTargetPropertyOnSource(): void { - $descriptor = new QueryFieldDescriptor(); - $descriptor->setTargetPropertyOnSource('test'); + $descriptor = new QueryFieldDescriptor( + name: 'test', + type: Type::string(), + targetClass: stdClass::class, + targetPropertyOnSource: 'test', + ); $descriptor->getResolver(); $this->expectException(GraphQLRuntimeException::class); - $descriptor->setTargetPropertyOnSource('test'); + $descriptor->withTargetPropertyOnSource(stdClass::class, 'test'); } public function testExceptionInSetMagicProperty(): void { - $descriptor = new QueryFieldDescriptor(); - $descriptor->setMagicProperty('test'); + $descriptor = new QueryFieldDescriptor( + name: 'test', + type: Type::string(), + targetClass: stdClass::class, + magicProperty: 'test' + ); $descriptor->getResolver(); $this->expectException(GraphQLRuntimeException::class); - $descriptor->setMagicProperty('test'); + $descriptor->withMagicProperty(stdClass::class, 'test'); } public function testExceptionInGetOriginalResolver(): void { - $descriptor = new QueryFieldDescriptor(); + $descriptor = new QueryFieldDescriptor('test', Type::string()); $this->expectException(GraphQLRuntimeException::class); $descriptor->getOriginalResolver(); } diff --git a/tests/QueryFieldTest.php b/tests/QueryFieldTest.php index 91e6251ddd..278c31b586 100644 --- a/tests/QueryFieldTest.php +++ b/tests/QueryFieldTest.php @@ -9,14 +9,14 @@ use GraphQL\Type\Definition\Type; use PHPUnit\Framework\TestCase; use TheCodingMachine\GraphQLite\Fixtures\TestObject; -use TheCodingMachine\GraphQLite\Middlewares\SourceResolver; +use TheCodingMachine\GraphQLite\Middlewares\SourceMethodResolver; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; class QueryFieldTest extends TestCase { public function testExceptionsHandling(): void { - $sourceResolver = new SourceResolver('getTest'); + $sourceResolver = new SourceMethodResolver(TestObject::class, 'getTest'); $queryField = new QueryField('foo', Type::string(), [ new class implements ParameterInterface { public function resolve(?object $source, array $args, mixed $context, ResolveInfo $info): mixed diff --git a/website/docs/custom-types.mdx b/website/docs/custom-types.mdx index 34079ba2ab..3943b22519 100644 --- a/website/docs/custom-types.mdx +++ b/website/docs/custom-types.mdx @@ -140,17 +140,17 @@ The easiest way is to use a `StaticTypeMapper`. Use this class to register custo ```php // Sample code: -$staticTypeMapper = new StaticTypeMapper(); - -// Let's register a type that maps by default to the "MyClass" PHP class -$staticTypeMapper->setTypes([ - MyClass::class => new MyCustomOutputType() -]); - -// If you don't want your output type to map to any PHP class by default, use: -$staticTypeMapper->setNotMappedTypes([ - new MyCustomOutputType() -]); +$staticTypeMapper = new StaticTypeMapper( + // Let's register a type that maps by default to the "MyClass" PHP class + types: [ + MyClass::class => new MyCustomOutputType() + ], + + // If you don't want your output type to map to any PHP class by default, use: + notMappedTypes: [ + new MyCustomOutputType() + ], +); // Register the static type mapper in your application using the SchemaFactory instance $schemaFactory->addTypeMapper($staticTypeMapper); diff --git a/website/docs/field-middlewares.md b/website/docs/field-middlewares.md index 07dafadb29..8532a40688 100644 --- a/website/docs/field-middlewares.md +++ b/website/docs/field-middlewares.md @@ -38,26 +38,26 @@ interface FieldMiddlewareInterface class QueryFieldDescriptor { public function getName() { /* ... */ } - public function setName(string $name) { /* ... */ } + public function withName(string $name): self { /* ... */ } public function getType() { /* ... */ } - public function setType($type): void { /* ... */ } + public function withType($type): self { /* ... */ } public function getParameters(): array { /* ... */ } - public function setParameters(array $parameters): void { /* ... */ } + public function withParameters(array $parameters): self { /* ... */ } public function getPrefetchParameters(): array { /* ... */ } - public function setPrefetchParameters(array $prefetchParameters): void { /* ... */ } + public function withPrefetchParameters(array $prefetchParameters): self { /* ... */ } public function getPrefetchMethodName(): ?string { /* ... */ } - public function setPrefetchMethodName(?string $prefetchMethodName): void { /* ... */ } - public function setCallable(callable $callable): void { /* ... */ } - public function setTargetMethodOnSource(?string $targetMethodOnSource): void { /* ... */ } + public function withPrefetchMethodName(?string $prefetchMethodName): self { /* ... */ } + public function withCallable(callable $callable): self { /* ... */ } + public function withTargetMethodOnSource(?string $targetMethodOnSource): self { /* ... */ } public function isInjectSource(): bool { /* ... */ } - public function setInjectSource(bool $injectSource): void { /* ... */ } + public function withInjectSource(bool $injectSource): self { /* ... */ } public function getComment(): ?string { /* ... */ } - public function setComment(?string $comment): void { /* ... */ } + public function withComment(?string $comment): self { /* ... */ } public function getMiddlewareAnnotations(): MiddlewareAnnotations { /* ... */ } - public function setMiddlewareAnnotations(MiddlewareAnnotations $middlewareAnnotations): void { /* ... */ } + public function withMiddlewareAnnotations(MiddlewareAnnotations $middlewareAnnotations): self { /* ... */ } public function getOriginalResolver(): ResolverInterface { /* ... */ } public function getResolver(): callable { /* ... */ } - public function setResolver(callable $resolver): void { /* ... */ } + public function withResolver(callable $resolver): self { /* ... */ } } ``` From d0932491c76de5a11d7a97ae4b14ed950e850625 Mon Sep 17 00:00:00 2001 From: oprypkhantc <54406427+oprypkhantc@users.noreply.github.com> Date: Fri, 4 Aug 2023 00:00:36 +0300 Subject: [PATCH 025/108] Prefetch refactor (#588) * Make parts of graphqlite immutable * Revert previous versions docs * Refactor getters, PHPStan, codestyle * Fix phpcbf's broken Cloneable * Fix return types in docs * Fix "test" class names used for resolvers * Refactor GlobAnnotationsCache to be immutable * Refactor prefetch * Reformat * Change docs for new prefetch * Tests coverage for new Prefetch * Reformat using cs-fix * Fix failing test on --prefer-lowest * Fix failing test on --prefer-lowest * Add test for ParameterizedCallableResolverTest --- src/Annotations/Field.php | 15 ++ src/Annotations/Prefetch.php | 23 +++ src/FieldsBuilder.php | 139 +++++++++--------- src/InputTypeUtils.php | 33 ++++- src/InvalidCallableRuntimeException.php | 15 ++ src/InvalidPrefetchMethodRuntimeException.php | 13 +- .../PrefetchParameterMiddleware.php | 57 +++++++ src/ParameterizedCallableResolver.php | 60 ++++++++ src/Parameters/ExpandsInputTypeParameters.php | 11 ++ src/Parameters/PrefetchDataParameter.php | 70 +++++---- src/QueryField.php | 85 ++++------- src/QueryFieldDescriptor.php | 25 ---- src/SchemaFactory.php | 46 +++--- tests/AbstractQueryProviderTest.php | 54 ++++--- tests/FieldsBuilderTest.php | 32 ++-- tests/Fixtures/Integration/Models/Contact.php | 7 +- .../Integration/Types/ContactType.php | 7 +- .../TestTypeWithInvalidPrefetchMethod.php | 5 +- .../TestTypeWithInvalidPrefetchParameter.php | 28 ---- tests/Fixtures/TestTypeWithPrefetchMethod.php | 28 ---- .../Fixtures/TestTypeWithPrefetchMethods.php | 45 ++++++ tests/InputTypeUtilsTest.php | 37 ++++- tests/Integration/EndToEndTest.php | 33 +++-- .../Mappers/Parameters/HardCodedParameter.php | 2 +- .../PrefetchParameterMiddlewareTest.php | 110 ++++++++++++++ tests/ParameterizedCallableResolverTest.php | 85 +++++++++++ .../Parameters/PrefetchDataParameterTest.php | 86 +++++++++++ tests/QueryFieldTest.php | 2 +- website/docs/annotations-reference.md | 11 ++ website/docs/field-middlewares.md | 4 - website/docs/prefetch-method.mdx | 25 ++-- 31 files changed, 848 insertions(+), 345 deletions(-) create mode 100644 src/Annotations/Prefetch.php create mode 100644 src/InvalidCallableRuntimeException.php create mode 100644 src/Mappers/Parameters/PrefetchParameterMiddleware.php create mode 100644 src/ParameterizedCallableResolver.php create mode 100644 src/Parameters/ExpandsInputTypeParameters.php delete mode 100644 tests/Fixtures/TestTypeWithInvalidPrefetchParameter.php delete mode 100644 tests/Fixtures/TestTypeWithPrefetchMethod.php create mode 100644 tests/Fixtures/TestTypeWithPrefetchMethods.php create mode 100644 tests/Mappers/Parameters/PrefetchParameterMiddlewareTest.php create mode 100644 tests/ParameterizedCallableResolverTest.php create mode 100644 tests/Parameters/PrefetchDataParameterTest.php diff --git a/src/Annotations/Field.php b/src/Annotations/Field.php index b64ed5d3a0..8e4112c535 100644 --- a/src/Annotations/Field.php +++ b/src/Annotations/Field.php @@ -6,6 +6,10 @@ use Attribute; +use function trigger_error; + +use const E_USER_DEPRECATED; + /** * @Annotation * @Target({"PROPERTY", "METHOD"}) @@ -44,6 +48,7 @@ class Field extends AbstractRequest public function __construct(array $attributes = [], string|null $name = null, string|null $outputType = null, string|null $prefetchMethod = null, string|array|null $for = null, string|null $description = null, string|null $inputType = null) { parent::__construct($attributes, $name, $outputType); + $this->prefetchMethod = $prefetchMethod ?? $attributes['prefetchMethod'] ?? null; $this->description = $description ?? $attributes['description'] ?? null; $this->inputType = $inputType ?? $attributes['inputType'] ?? null; @@ -54,6 +59,16 @@ public function __construct(array $attributes = [], string|null $name = null, st } $this->for = (array) $forValue; + + if (! $this->prefetchMethod) { + return; + } + + trigger_error( + "Using #[Field(prefetchMethod='" . $this->prefetchMethod . "')] on fields is deprecated in favor " . + "of #[Prefetch('" . $this->prefetchMethod . "')] \$data attribute on the parameter itself.", + E_USER_DEPRECATED, + ); } /** diff --git a/src/Annotations/Prefetch.php b/src/Annotations/Prefetch.php new file mode 100644 index 0000000000..f4fa3bbeb7 --- /dev/null +++ b/src/Annotations/Prefetch.php @@ -0,0 +1,23 @@ + Returns an array of parameters. */ - public function getParameters(ReflectionMethod $refMethod): array + public function getParameters(ReflectionMethod $refMethod, int $skip = 0): array { $docBlockObj = $this->cachedDocBlockFactory->getDocBlock($refMethod); //$docBlockComment = $docBlockObj->getSummary()."\n".$docBlockObj->getDescription()->render(); - $parameters = $refMethod->getParameters(); + $parameters = array_slice($refMethod->getParameters(), $skip); return $this->mapParameters($parameters, $docBlockObj); } @@ -245,19 +248,8 @@ public function getParameters(ReflectionMethod $refMethod): array */ public function getParametersForDecorator(ReflectionMethod $refMethod): array { - $docBlockObj = $this->cachedDocBlockFactory->getDocBlock($refMethod); - //$docBlockComment = $docBlockObj->getSummary()."\n".$docBlockObj->getDescription()->render(); - - $parameters = $refMethod->getParameters(); - - if (empty($parameters)) { - return []; - } - - // Let's remove the first parameter. - array_shift($parameters); - - return $this->mapParameters($parameters, $docBlockObj); + // First parameter of a decorator is always $source so we're skipping that. + return $this->getParameters($refMethod, 1); } /** @@ -346,7 +338,7 @@ private function getFieldsByMethodAnnotations(string|object $controller, Reflect continue; } $for = $queryAnnotation->getFor(); - if ($typeName && $for && ! in_array($typeName, $for)) { + if ($typeName && $for && !in_array($typeName, $for)) { continue; } @@ -357,7 +349,7 @@ private function getFieldsByMethodAnnotations(string|object $controller, Reflect $name = $queryAnnotation->getName() ?: $this->namingStrategy->getFieldNameFromMethodName($methodName); - if (! $description) { + if (!$description) { $description = $docBlockObj->getSummary() . "\n" . $docBlockObj->getDescription()->render(); } @@ -381,27 +373,26 @@ private function getFieldsByMethodAnnotations(string|object $controller, Reflect refMethod: $refMethod, ); - [$prefetchMethodName, $prefetchArgs, $prefetchRefMethod] = $this->getPrefetchMethodInfo($refClass, $refMethod, $queryAnnotation); - if ($prefetchMethodName) { - $fieldDescriptor = $fieldDescriptor - ->withPrefetchMethodName($prefetchMethodName) - ->withPrefetchParameters($prefetchArgs); - } - $parameters = $refMethod->getParameters(); if ($injectSource === true) { $firstParameter = array_shift($parameters); // TODO: check that $first_parameter type is correct. } - if ($prefetchMethodName !== null && $prefetchRefMethod !== null) { - $secondParameter = array_shift($parameters); - if ($secondParameter === null) { - throw InvalidPrefetchMethodRuntimeException::prefetchDataIgnored($prefetchRefMethod, $injectSource); - } + + // TODO: remove once support for deprecated prefetchMethod on Field is removed. + $prefetchDataParameter = $this->getPrefetchParameter($name, $refClass, $refMethod, $queryAnnotation); + + if ($prefetchDataParameter) { + array_shift($parameters); } $args = $this->mapParameters($parameters, $docBlockObj); + // TODO: remove once support for deprecated prefetchMethod on Field is removed. + if ($prefetchDataParameter) { + $args = ['__graphqlite_prefectData' => $prefetchDataParameter, ...$args]; + } + $fieldDescriptor = $fieldDescriptor->withParameters($args); if (is_string($controller)) { @@ -454,7 +445,7 @@ private function getFieldsByPropertyAnnotations(string|object $controller, Refle if ($queryAnnotation instanceof Field) { $for = $queryAnnotation->getFor(); - if ($typeName && $for && ! in_array($typeName, $for)) { + if ($typeName && $for && !in_array($typeName, $for)) { continue; } @@ -465,7 +456,7 @@ private function getFieldsByPropertyAnnotations(string|object $controller, Refle $name = $queryAnnotation->getName() ?: $refProperty->getName(); - if (! $description) { + if (!$description) { $description = $docBlock->getSummary() . PHP_EOL . $docBlock->getDescription()->render(); /** @var Var_[] $varTags */ @@ -492,13 +483,6 @@ private function getFieldsByPropertyAnnotations(string|object $controller, Refle refProperty: $refProperty, ); - [$prefetchMethodName, $prefetchArgs] = $this->getPrefetchMethodInfo($refClass, $refProperty, $queryAnnotation); - if ($prefetchMethodName) { - $fieldDescriptor = $fieldDescriptor - ->withPrefetchMethodName($prefetchMethodName) - ->withPrefetchParameters($prefetchArgs); - } - if (is_string($controller)) { $fieldDescriptor = $fieldDescriptor->withTargetPropertyOnSource($refProperty->getDeclaringClass()->getName(), $refProperty->getName()); } else { @@ -559,7 +543,7 @@ private function getQueryFieldsFromSourceFields(array $sourceFields, ReflectionC $typeName = $extendTypeField->getName(); assert($typeName !== null); $targetedType = $this->recursiveTypeMapper->mapNameToType($typeName); - if (! $targetedType instanceof MutableObjectType) { + if (!$targetedType instanceof MutableObjectType) { throw CannotMapTypeException::extendTypeWithBadTargetedClass($refClass->getName(), $extendTypeField); } $objectClass = $targetedType->getMappedClassName(); @@ -578,7 +562,7 @@ private function getQueryFieldsFromSourceFields(array $sourceFields, ReflectionC $queryList = []; foreach ($sourceFields as $sourceField) { - if (! $sourceField->shouldFetchFromMagicProperty()) { + if (!$sourceField->shouldFetchFromMagicProperty()) { try { $refMethod = $this->getMethodFromPropertyName($objectRefClass, $sourceField->getSourceName() ?? $sourceField->getName()); } catch (FieldNotFoundException $e) { @@ -592,7 +576,7 @@ private function getQueryFieldsFromSourceFields(array $sourceFields, ReflectionC $deprecated = $docBlockObj->getTagsByName('deprecated'); if (count($deprecated) >= 1) { - $deprecationReason = trim((string) $deprecated[0]); + $deprecationReason = trim((string)$deprecated[0]); } $description = $sourceField->getDescription() ?? $docBlockComment; @@ -679,7 +663,7 @@ private function getMagicGetMethodFromSourceClassOrProxy(ReflectionClass $proxyR $sourceClassName = $typeField->getClass(); $sourceRefClass = new ReflectionClass($sourceClassName); - if (! $sourceRefClass->hasMethod($magicGet)) { + if (!$sourceRefClass->hasMethod($magicGet)) { throw MissingMagicGetException::cannotFindMagicGet($sourceClassName); } @@ -723,7 +707,7 @@ private function getMethodFromPropertyName(ReflectionClass $reflectionClass, str $methodName = $propertyName; } else { $methodName = PropertyAccessor::findGetter($reflectionClass->getName(), $propertyName); - if (! $methodName) { + if (!$methodName) { throw FieldNotFoundException::missingField($reflectionClass->getName(), $propertyName); } } @@ -755,7 +739,7 @@ private function mapParameters(array $refParameters, DocBlock $docBlock, SourceF foreach ($refParameters as $parameter) { $parameterAnnotations = $parameterAnnotationsPerParameter[$parameter->getName()] ?? new ParameterAnnotations([]); //$parameterAnnotations = $this->annotationReader->getParameterAnnotations($parameter); - if (! empty($additionalParameterAnnotations[$parameter->getName()])) { + if (!empty($additionalParameterAnnotations[$parameter->getName()])) { $parameterAnnotations->merge($additionalParameterAnnotations[$parameter->getName()]); unset($additionalParameterAnnotations[$parameter->getName()]); } @@ -766,7 +750,7 @@ private function mapParameters(array $refParameters, DocBlock $docBlock, SourceF } // Sanity check, are the parameters declared in $additionalParameterAnnotations available in $refParameters? - if (! empty($additionalParameterAnnotations)) { + if (!empty($additionalParameterAnnotations)) { $refParameter = reset($refParameters); foreach ($additionalParameterAnnotations as $parameterName => $parameterAnnotations) { foreach ($parameterAnnotations->getAllAnnotations() as $annotation) { @@ -787,7 +771,7 @@ private function getDeprecationReason(DocBlock $docBlockObj): string|null { $deprecated = $docBlockObj->getTagsByName('deprecated'); if (count($deprecated) >= 1) { - return trim((string) $deprecated[0]); + return trim((string)$deprecated[0]); } return null; @@ -796,16 +780,17 @@ private function getDeprecationReason(DocBlock $docBlockObj): string|null /** * Extracts prefetch method info from annotation. * - * @return array{0: string|null, 1: array, 2: ReflectionMethod|null} + * TODO: remove once support for deprecated prefetchMethod on Field is removed. * * @throws InvalidArgumentException */ - private function getPrefetchMethodInfo(ReflectionClass $refClass, ReflectionMethod|ReflectionProperty $reflector, object $annotation): array + private function getPrefetchParameter( + string $fieldName, + ReflectionClass $refClass, + ReflectionMethod|ReflectionProperty $reflector, + object $annotation, + ): PrefetchDataParameter|null { - $prefetchMethodName = null; - $prefetchArgs = []; - $prefetchRefMethod = null; - if ($annotation instanceof Field) { $prefetchMethodName = $annotation->getPrefetchMethod(); if ($prefetchMethodName !== null) { @@ -820,10 +805,20 @@ private function getPrefetchMethodInfo(ReflectionClass $refClass, ReflectionMeth $prefetchDocBlockObj = $this->cachedDocBlockFactory->getDocBlock($prefetchRefMethod); $prefetchArgs = $this->mapParameters($prefetchParameters, $prefetchDocBlockObj); + + return new PrefetchDataParameter( + fieldName: $fieldName, + resolver: static function (array $sources, ...$args) use ($prefetchMethodName) { + $source = $sources[0]; + + return $source->{$prefetchMethodName}($sources, ...$args); + }, + parameters: $prefetchArgs, + ); } } - return [$prefetchMethodName, $prefetchArgs, $prefetchRefMethod]; + return null; } /** @@ -843,23 +838,23 @@ private function getInputFieldsByMethodAnnotations(string|object $controller, Re $annotations = $this->annotationReader->getMethodAnnotations($refMethod, $annotationName); foreach ($annotations as $fieldAnnotations) { $description = null; - if (! ($fieldAnnotations instanceof Field)) { + if (!($fieldAnnotations instanceof Field)) { continue; } $for = $fieldAnnotations->getFor(); - if ($typeName && $for && ! in_array($typeName, $for)) { + if ($typeName && $for && !in_array($typeName, $for)) { continue; } $description = $fieldAnnotations->getDescription(); $docBlockObj = $this->cachedDocBlockFactory->getDocBlock($refMethod); $methodName = $refMethod->getName(); - if (! str_starts_with($methodName, 'set')) { + if (!str_starts_with($methodName, 'set')) { continue; } $name = $fieldAnnotations->getName() ?: $this->namingStrategy->getInputFieldNameFromMethodName($methodName); - if (! $description) { + if (!$description) { $description = $docBlockObj->getSummary() . "\n" . $docBlockObj->getDescription()->render(); } @@ -902,7 +897,7 @@ private function getInputFieldsByMethodAnnotations(string|object $controller, Re ->withHasDefaultValue($isUpdate) ->withDefaultValue($args[$name]->getDefaultValue()); $constructerParameters = $this->getClassConstructParameterNames($refClass); - if (! in_array($name, $constructerParameters)) { + if (!in_array($name, $constructerParameters)) { $inputFieldDescriptor = $inputFieldDescriptor->withTargetMethodOnSource($refMethod->getDeclaringClass()->getName(), $methodName); } @@ -946,12 +941,12 @@ private function getInputFieldsByPropertyAnnotations(string|object $controller, foreach ($annotations as $annotation) { $description = null; - if (! ($annotation instanceof Field)) { + if (!($annotation instanceof Field)) { continue; } $for = $annotation->getFor(); - if ($typeName && $for && ! in_array($typeName, $for)) { + if ($typeName && $for && !in_array($typeName, $for)) { continue; } @@ -961,7 +956,7 @@ private function getInputFieldsByPropertyAnnotations(string|object $controller, $constructerParameters = $this->getClassConstructParameterNames($refClass); $inputProperty = $this->typeMapper->mapInputProperty($refProperty, $docBlock, $name, $inputType, $defaultProperties[$refProperty->getName()] ?? null, $isUpdate ? true : null); - if (! $description) { + if (!$description) { $description = $inputProperty->getDescription(); } @@ -984,7 +979,7 @@ private function getInputFieldsByPropertyAnnotations(string|object $controller, ); } else { $type = $inputProperty->getType(); - if (! $inputType && $isUpdate && $type instanceof NonNull) { + if (!$inputType && $isUpdate && $type instanceof NonNull) { $type = $type->getWrappedType(); } assert($type instanceof InputType); @@ -1028,7 +1023,7 @@ private function getClassConstructParameterNames(ReflectionClass $refClass): arr { $constructor = $refClass->getConstructor(); - if (! $constructor) { + if (!$constructor) { return []; } diff --git a/src/InputTypeUtils.php b/src/InputTypeUtils.php index 3c38de38aa..ec155b2481 100644 --- a/src/InputTypeUtils.php +++ b/src/InputTypeUtils.php @@ -17,10 +17,10 @@ use ReflectionMethod; use ReflectionNamedType; use RuntimeException; +use TheCodingMachine\GraphQLite\Parameters\ExpandsInputTypeParameters; use TheCodingMachine\GraphQLite\Parameters\InputTypeParameterInterface; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; -use function array_filter; use function array_map; use function assert; use function ltrim; @@ -99,6 +99,33 @@ private function resolveSelf(Type $type, ReflectionClass $reflectionClass): Type return $type; } + /** + * @param array $parameters + * + * @return array + */ + public static function toInputParameters(array $parameters): array + { + $result = []; + + foreach ($parameters as $name => $parameter) { + if ($parameter instanceof InputTypeParameterInterface) { + $result[$name] = $parameter; + } + + if (! ($parameter instanceof ExpandsInputTypeParameters)) { + continue; + } + + $result = [ + ...$result, + ...$parameter->toInputTypeParameters(), + ]; + } + + return $result; + } + /** * Maps an array of ParameterInterface to an array of field descriptors as accepted by Webonyx. * @@ -108,9 +135,7 @@ private function resolveSelf(Type $type, ReflectionClass $reflectionClass): Type */ public static function getInputTypeArgs(array $args): array { - $inputTypeArgs = array_filter($args, static function (ParameterInterface $parameter) { - return $parameter instanceof InputTypeParameterInterface; - }); + $inputTypeArgs = self::toInputParameters($args); return array_map(static function (InputTypeParameterInterface $parameter): array { $desc = [ diff --git a/src/InvalidCallableRuntimeException.php b/src/InvalidCallableRuntimeException.php new file mode 100644 index 0000000000..f35006c8c5 --- /dev/null +++ b/src/InvalidCallableRuntimeException.php @@ -0,0 +1,15 @@ +getDeclaringClass()->getName() . '::' . $reflector->getName() . ' specifies a "prefetch method" that could not be found. Unable to find method ' . $reflectionClass->getName() . '::' . $methodName . '.', 0, $previous); } - public static function prefetchDataIgnored(ReflectionMethod $annotationMethod, bool $isSecond): self + public static function fromInvalidCallable( + ReflectionMethod $reflector, + string $parameterName, + InvalidCallableRuntimeException $e, + ): self { - throw new self('The @Field annotation in ' . $annotationMethod->getDeclaringClass()->getName() . '::' . $annotationMethod->getName() . ' specifies a "prefetch method" but the data from the prefetch method is not gathered. The "' . $annotationMethod->getName() . '" method should accept a ' . ($isSecond ? 'second' : 'first') . ' parameter that will contain data returned by the prefetch method.'); + return new self( + '#[Prefetch] attribute on parameter $' . $parameterName . ' in ' . $reflector->getDeclaringClass()->getName() . '::' . $reflector->getName() . + ' specifies a callable that is invalid: ' . $e->getMessage(), + previous: $e, + ); } } diff --git a/src/Mappers/Parameters/PrefetchParameterMiddleware.php b/src/Mappers/Parameters/PrefetchParameterMiddleware.php new file mode 100644 index 0000000000..bb5fef4f70 --- /dev/null +++ b/src/Mappers/Parameters/PrefetchParameterMiddleware.php @@ -0,0 +1,57 @@ +getAnnotationByType(Prefetch::class); + + if ($prefetch === null) { + return $next->mapParameter($parameter, $docBlock, $paramTagType, $parameterAnnotations); + } + + $method = $parameter->getDeclaringFunction(); + + assert($method instanceof ReflectionMethod); + + // Map callable specified by #[Prefetch] into a real callable and parse all of the GraphQL parameters. + try { + [$resolver, $parameters] = $this->parameterizedCallableResolver->resolve($prefetch->callable, $method->getDeclaringClass(), 1); + } catch (InvalidCallableRuntimeException $e) { + throw InvalidPrefetchMethodRuntimeException::fromInvalidCallable($method, $parameter->getName(), $e); + } + + return new PrefetchDataParameter( + fieldName: $method->getName(), + resolver: $resolver, + parameters: $parameters, + ); + } +} diff --git a/src/ParameterizedCallableResolver.php b/src/ParameterizedCallableResolver.php new file mode 100644 index 0000000000..a1abc33d69 --- /dev/null +++ b/src/ParameterizedCallableResolver.php @@ -0,0 +1,60 @@ +} + */ + public function resolve(string|array $callable, string|ReflectionClass $classContext, int $skip = 0): array + { + if ($classContext instanceof ReflectionClass) { + $classContext = $classContext->getName(); + } + + // If string method is given, it's equivalent to [self::class, 'method'] + if (is_string($callable)) { + $callable = [$classContext, $callable]; + } + + try { + $refMethod = new ReflectionMethod($callable[0], $callable[1]); + } catch (ReflectionException $e) { + throw InvalidCallableRuntimeException::methodNotFound($callable[0], $callable[1], $e); + } + + // If method isn't static, then we should try to resolve the class name through the container. + if (! $refMethod->isStatic()) { + $callable = fn (...$args) => $this->container->get($callable[0])->{$callable[1]}(...$args); + } + + assert(is_callable($callable)); + + // Map all parameters of the callable. + $parameters = $this->fieldsBuilder->getParameters($refMethod, $skip); + + return [$callable, $parameters]; + } +} diff --git a/src/Parameters/ExpandsInputTypeParameters.php b/src/Parameters/ExpandsInputTypeParameters.php new file mode 100644 index 0000000000..39c3b0e34c --- /dev/null +++ b/src/Parameters/ExpandsInputTypeParameters.php @@ -0,0 +1,11 @@ + */ + public function toInputTypeParameters(): array; +} diff --git a/src/Parameters/PrefetchDataParameter.php b/src/Parameters/PrefetchDataParameter.php index 76036fb0ab..fe81e4aceb 100644 --- a/src/Parameters/PrefetchDataParameter.php +++ b/src/Parameters/PrefetchDataParameter.php @@ -4,69 +4,83 @@ namespace TheCodingMachine\GraphQLite\Parameters; +use GraphQL\Deferred; use GraphQL\Type\Definition\ResolveInfo; use TheCodingMachine\GraphQLite\Context\ContextInterface; use TheCodingMachine\GraphQLite\GraphQLRuntimeException; -use TheCodingMachine\GraphQLite\Middlewares\ResolverInterface; +use TheCodingMachine\GraphQLite\InputTypeUtils; use TheCodingMachine\GraphQLite\PrefetchBuffer; use TheCodingMachine\GraphQLite\QueryField; -use function array_unshift; use function assert; -use function is_callable; /** * Typically the first parameter of "self" fields or the second parameter of "external" fields that will be filled with the data fetched from the prefetch method. */ -class PrefetchDataParameter implements ParameterInterface +class PrefetchDataParameter implements ParameterInterface, ExpandsInputTypeParameters { - /** @param array $parameters Indexed by argument name. */ + /** + * @param callable $resolver + * @param array $parameters Indexed by argument name. + */ public function __construct( private readonly string $fieldName, - private readonly ResolverInterface $originalResolver, - private readonly string $methodName, - private readonly array $parameters, + private readonly mixed $resolver, + public readonly array $parameters, ) { } /** @param array $args */ - public function resolve(object|null $source, array $args, mixed $context, ResolveInfo $info): mixed + public function resolve(object|null $source, array $args, mixed $context, ResolveInfo $info): Deferred { + assert($source !== null); + // The PrefetchBuffer must be tied to the current request execution. The only object we have for this is $context // $context MUST be a ContextInterface if (! $context instanceof ContextInterface) { - throw new GraphQLRuntimeException('When using "prefetch", you sure ensure that the GraphQL execution "context" (passed to the GraphQL::executeQuery method) is an instance of \TheCodingMachine\GraphQLite\Context\Context'); + throw new GraphQLRuntimeException('When using "prefetch", you should ensure that the GraphQL execution "context" (passed to the GraphQL::executeQuery method) is an instance of \TheCodingMachine\GraphQLite\Context\Context'); } $prefetchBuffer = $context->getPrefetchBuffer($this); + $prefetchBuffer->register($source, $args); - if (! $prefetchBuffer->hasResult($args)) { - $prefetchResult = $this->computePrefetch($source, $args, $context, $info, $prefetchBuffer); + // The way this works is simple: GraphQL first iterates over every requested field and calls ->resolve() + // on it. That, in turn, calls this method. GraphQL doesn't need the actual value just yet; it simply + // calls ->resolve to let developers do complex value fetching. + // + // So we record all of these ->resolve() calls, collect them together and when a value is actually + // needed, GraphQL calls the callback of Deferred below. That's when we call the prefetch method, + // already knowing all the requested fields (source-arguments combinations). + return new Deferred(function () use ($info, $context, $args, $prefetchBuffer) { + if (! $prefetchBuffer->hasResult($args)) { + $prefetchResult = $this->computePrefetch($args, $context, $info, $prefetchBuffer); - $prefetchBuffer->storeResult($prefetchResult, $args); - } + $prefetchBuffer->storeResult($prefetchResult, $args); + } - return $prefetchResult ?? $prefetchBuffer->getResult($args); + return $prefetchResult ?? $prefetchBuffer->getResult($args); + }); } /** @param array $args */ - private function computePrefetch(object|null $source, array $args, mixed $context, ResolveInfo $info, PrefetchBuffer $prefetchBuffer): mixed + private function computePrefetch(array $args, mixed $context, ResolveInfo $info, PrefetchBuffer $prefetchBuffer): mixed { - // TODO: originalPrefetchResolver and prefetchResolver needed!!! - $prefetchCallable = [ - $this->originalResolver->executionSource($source), - $this->methodName, - ]; - $sources = $prefetchBuffer->getObjectsByArguments($args); + $toPassPrefetchArgs = QueryField::paramsToArguments($this->fieldName, $this->parameters, null, $args, $context, $info, $this->resolver); - assert(is_callable($prefetchCallable)); - $toPassPrefetchArgs = QueryField::paramsToArguments($this->fieldName, $this->parameters, $source, $args, $context, $info, $prefetchCallable); - - array_unshift($toPassPrefetchArgs, $sources); - assert(is_callable($prefetchCallable)); + return ($this->resolver)($sources, ...$toPassPrefetchArgs); + } - return $prefetchCallable(...$toPassPrefetchArgs); + /** @inheritDoc */ + public function toInputTypeParameters(): array + { + // Given these signatures: + // function name(#[Prefetch('prefetch1') $data1, string $arg2, #[Prefetch('prefetch2') $data2) + // function prefetch1(iterable $sources, int $arg1) + // function prefetch2(iterable $sources, int $arg3) + // Then field `name` in GraphQL scheme should look like so: name(arg1: Int!, arg2: String!, arg3: Int!) + // That's exactly what we're doing here - adding `arg1` and `arg3` from prefetch methods as input params + return InputTypeUtils::toInputParameters($this->parameters); } } diff --git a/src/QueryField.php b/src/QueryField.php index 69c8f569fb..b95a9152f4 100644 --- a/src/QueryField.php +++ b/src/QueryField.php @@ -6,6 +6,9 @@ use GraphQL\Deferred; use GraphQL\Error\ClientAware; +use GraphQL\Executor\Promise\Adapter\SyncPromise; +use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter; +use GraphQL\Executor\Promise\Promise; use GraphQL\Language\AST\FieldDefinitionNode; use GraphQL\Type\Definition\FieldDefinition; use GraphQL\Type\Definition\ListOfType; @@ -13,15 +16,14 @@ use GraphQL\Type\Definition\OutputType; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; -use TheCodingMachine\GraphQLite\Context\ContextInterface; use TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException; use TheCodingMachine\GraphQLite\Middlewares\ResolverInterface; use TheCodingMachine\GraphQLite\Parameters\MissingArgumentException; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; -use TheCodingMachine\GraphQLite\Parameters\PrefetchDataParameter; use TheCodingMachine\GraphQLite\Parameters\SourceParameter; -use function assert; +use function array_filter; +use function array_map; /** * A GraphQL field that maps to a PHP method automatically. @@ -39,7 +41,6 @@ final class QueryField extends FieldDefinition * @param array $arguments Indexed by argument name. * @param ResolverInterface $originalResolver A pointer to the resolver being called (but not wrapped by any field middleware) * @param callable $resolver The resolver actually called - * @param array $prefetchArgs Indexed by argument name. * @param array{resolve?: FieldResolver|null,args?: ArgumentListConfig|null,description?: string|null,deprecationReason?: string|null,astNode?: FieldDefinitionNode|null,complexity?: ComplexityFn|null} $additionalConfig */ public function __construct( @@ -50,15 +51,13 @@ public function __construct( callable $resolver, string|null $comment, string|null $deprecationReason, - string|null $prefetchMethodName, - array $prefetchArgs, array $additionalConfig = [], ) { $config = [ 'name' => $name, 'type' => $type, - 'args' => InputTypeUtils::getInputTypeArgs($prefetchArgs + $arguments), + 'args' => InputTypeUtils::getInputTypeArgs($arguments), ]; if ($comment) { $config['description'] = $comment; @@ -67,7 +66,7 @@ public function __construct( $config['deprecationReason'] = $deprecationReason; } - $resolveFn = function (object|null $source, array $args, $context, ResolveInfo $info) use ($name, $arguments, $originalResolver, $resolver) { + $config['resolve'] = function (object|null $source, array $args, $context, ResolveInfo $info) use ($name, $arguments, $originalResolver, $resolver) { /*if ($resolve !== null) { $method = $resolve; } elseif ($targetMethodOnSource !== null) { @@ -77,47 +76,39 @@ public function __construct( }*/ $toPassArgs = self::paramsToArguments($name, $arguments, $source, $args, $context, $info, $resolver); - $result = $resolver($source, ...$toPassArgs); + $callResolver = function (...$args) use ($originalResolver, $source, $resolver) { + $result = $resolver($source, ...$args); - try { - $this->assertReturnType($result); - } catch (TypeMismatchRuntimeException $e) { - $e->addInfo($this->name, $originalResolver->toString()); - - throw $e; - } + try { + $this->assertReturnType($result); + } catch (TypeMismatchRuntimeException $e) { + $e->addInfo($this->name, $originalResolver->toString()); - return $result; - }; - - if ($prefetchMethodName === null) { - $config['resolve'] = $resolveFn; - } else { - $config['resolve'] = static function ($source, array $args, $context, ResolveInfo $info) use ($arguments, $resolveFn) { - // The PrefetchBuffer must be tied to the current request execution. The only object we have for this is $context - // $context MUST be a ContextInterface - if (! $context instanceof ContextInterface) { - throw new GraphQLRuntimeException('When using "prefetch", you sure ensure that the GraphQL execution "context" (passed to the GraphQL::executeQuery method) is an instance of \TheCodingMachine\GraphQLite\Context\Context'); + throw $e; } - // TODO: this is to be refactored in a prefetch refactor PR that follows. For now this hack will do. - foreach ($arguments as $argument) { - if ($argument instanceof PrefetchDataParameter) { - $prefetchArgument = $argument; + return $result; + }; - break; - } - } + $deferred = (bool) array_filter($toPassArgs, static fn (mixed $value) => $value instanceof SyncPromise); - assert(($prefetchArgument ?? null) !== null); + // GraphQL allows deferring resolving the field's value using promises, i.e. they call the resolve + // function ahead of time for all of the fields (allowing us to gather all calls and do something + // in batch, like prefetch) and then resolve the promises as needed. To support that for prefetch, + // we're checking if any of the resolved parameters returned a promise. If they did, we know + // that the value should also be resolved using a promise, so we're wrapping it in one. + return $deferred ? new Deferred(static function () use ($toPassArgs, $callResolver) { + $syncPromiseAdapter = new SyncPromiseAdapter(); - $context->getPrefetchBuffer($prefetchArgument)->register($source, $args); + // Wait for every deferred parameter. + $toPassArgs = array_map( + static fn (mixed $value) => $value instanceof SyncPromise ? $syncPromiseAdapter->wait(new Promise($value, $syncPromiseAdapter)) : $value, + $toPassArgs, + ); - return new Deferred(static function () use ($source, $args, $context, $info, $resolveFn) { - return $resolveFn($source, $args, $context, $info); - }); - }; - } + return $callResolver(...$toPassArgs); + }) : $callResolver(...$toPassArgs); + }; $config += $additionalConfig; @@ -174,24 +165,12 @@ private static function fromDescriptor(QueryFieldDescriptor $fieldDescriptor): s $fieldDescriptor->getResolver(), $fieldDescriptor->getComment(), $fieldDescriptor->getDeprecationReason(), - $fieldDescriptor->getPrefetchMethodName(), - $fieldDescriptor->getPrefetchParameters(), ); } public static function fromFieldDescriptor(QueryFieldDescriptor $fieldDescriptor): self { $arguments = $fieldDescriptor->getParameters(); - if ($fieldDescriptor->getPrefetchMethodName() !== null) { - $arguments = [ - '__graphqlite_prefectData' => new PrefetchDataParameter( - fieldName: $fieldDescriptor->getName(), - originalResolver: $fieldDescriptor->getOriginalResolver(), - methodName: $fieldDescriptor->getPrefetchMethodName(), - parameters: $fieldDescriptor->getPrefetchParameters(), - ), - ] + $arguments; - } if ($fieldDescriptor->isInjectSource() === true) { $arguments = ['__graphqlite_source' => new SourceParameter()] + $arguments; } diff --git a/src/QueryFieldDescriptor.php b/src/QueryFieldDescriptor.php index 7698651cba..5efa1de983 100644 --- a/src/QueryFieldDescriptor.php +++ b/src/QueryFieldDescriptor.php @@ -35,7 +35,6 @@ class QueryFieldDescriptor /** * @param array $parameters - * @param array $prefetchParameters * @param callable $callable * @param bool $injectSource Whether we should inject the source as the first parameter or not. */ @@ -43,8 +42,6 @@ public function __construct( private readonly string $name, private readonly OutputType&Type $type, private readonly array $parameters = [], - private readonly array $prefetchParameters = [], - private readonly string|null $prefetchMethodName = null, private readonly mixed $callable = null, private readonly string|null $targetClass = null, private readonly string|null $targetMethodOnSource = null, @@ -92,28 +89,6 @@ public function withParameters(array $parameters): self return $this->with(parameters: $parameters); } - /** @return array */ - public function getPrefetchParameters(): array - { - return $this->prefetchParameters; - } - - /** @param array $prefetchParameters */ - public function withPrefetchParameters(array $prefetchParameters): self - { - return $this->with(prefetchParameters: $prefetchParameters); - } - - public function getPrefetchMethodName(): string|null - { - return $this->prefetchMethodName; - } - - public function withPrefetchMethodName(string|null $prefetchMethodName): self - { - return $this->with(prefetchMethodName: $prefetchMethodName); - } - /** * Sets the callable targeting the resolver function if the resolver function is part of a service. * This should not be used in the context of a field middleware. diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index f25d584f05..b612ee745e 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -5,7 +5,6 @@ namespace TheCodingMachine\GraphQLite; use Doctrine\Common\Annotations\AnnotationReader as DoctrineAnnotationReader; -use Doctrine\Common\Annotations\AnnotationRegistry; use Doctrine\Common\Annotations\PsrCachedReader; use Doctrine\Common\Annotations\Reader; use GraphQL\Type\SchemaConfig; @@ -23,6 +22,7 @@ use TheCodingMachine\GraphQLite\Mappers\Parameters\InjectUserParameterHandler; use TheCodingMachine\GraphQLite\Mappers\Parameters\ParameterMiddlewareInterface; use TheCodingMachine\GraphQLite\Mappers\Parameters\ParameterMiddlewarePipe; +use TheCodingMachine\GraphQLite\Mappers\Parameters\PrefetchParameterMiddleware; use TheCodingMachine\GraphQLite\Mappers\Parameters\ResolveInfoParameterHandler; use TheCodingMachine\GraphQLite\Mappers\PorpaginasTypeMapper; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapper; @@ -337,19 +337,19 @@ public function setExpressionLanguage(ExpressionLanguage $expressionLanguage): s public function createSchema(): Schema { - $symfonyCache = new Psr16Adapter($this->cache, $this->cacheNamespace); - $annotationReader = new AnnotationReader($this->getDoctrineAnnotationReader($symfonyCache), AnnotationReader::LAX_MODE); - $authenticationService = $this->authenticationService ?: new FailAuthenticationService(); - $authorizationService = $this->authorizationService ?: new FailAuthorizationService(); - $typeResolver = new TypeResolver(); - $namespacedCache = new NamespacedCache($this->cache); - $cachedDocBlockFactory = new CachedDocBlockFactory($namespacedCache); - $namingStrategy = $this->namingStrategy ?: new NamingStrategy(); - $typeRegistry = new TypeRegistry(); + $symfonyCache = new Psr16Adapter($this->cache, $this->cacheNamespace); + $annotationReader = new AnnotationReader($this->getDoctrineAnnotationReader($symfonyCache), AnnotationReader::LAX_MODE); + $authenticationService = $this->authenticationService ?: new FailAuthenticationService(); + $authorizationService = $this->authorizationService ?: new FailAuthorizationService(); + $typeResolver = new TypeResolver(); + $namespacedCache = new NamespacedCache($this->cache); + $cachedDocBlockFactory = new CachedDocBlockFactory($namespacedCache); + $namingStrategy = $this->namingStrategy ?: new NamingStrategy(); + $typeRegistry = new TypeRegistry(); $namespaceFactory = new NamespaceFactory($namespacedCache, $this->classNameMapper, $this->globTTL); $nsList = array_map( - static fn (string $namespace) => $namespaceFactory->createNamespace($namespace), + static fn(string $namespace) => $namespaceFactory->createNamespace($namespace), $this->typeNamespaces, ); @@ -390,7 +390,7 @@ public function createSchema(): Schema $rootTypeMapper = new MyCLabsEnumTypeMapper($rootTypeMapper, $annotationReader, $symfonyCache, $nsList); } - if (! empty($this->rootTypeMapperFactories)) { + if (!empty($this->rootTypeMapperFactories)) { $rootSchemaFactoryContext = new RootTypeMapperFactoryContext( $annotationReader, $typeResolver, @@ -415,14 +415,7 @@ public function createSchema(): Schema $lastTopRootTypeMapper->setNext($rootTypeMapper); $argumentResolver = new ArgumentResolver(); - $parameterMiddlewarePipe = new ParameterMiddlewarePipe(); - foreach ($this->parameterMiddlewares as $parameterMapper) { - $parameterMiddlewarePipe->pipe($parameterMapper); - } - $parameterMiddlewarePipe->pipe(new ResolveInfoParameterHandler()); - $parameterMiddlewarePipe->pipe(new ContainerParameterHandler($this->container)); - $parameterMiddlewarePipe->pipe(new InjectUserParameterHandler($authenticationService)); $fieldsBuilder = new FieldsBuilder( $annotationReader, @@ -436,9 +429,18 @@ public function createSchema(): Schema $fieldMiddlewarePipe, $inputFieldMiddlewarePipe, ); + $parameterizedCallableResolver = new ParameterizedCallableResolver($fieldsBuilder, $this->container); + + foreach ($this->parameterMiddlewares as $parameterMapper) { + $parameterMiddlewarePipe->pipe($parameterMapper); + } + $parameterMiddlewarePipe->pipe(new ResolveInfoParameterHandler()); + $parameterMiddlewarePipe->pipe(new PrefetchParameterMiddleware($parameterizedCallableResolver)); + $parameterMiddlewarePipe->pipe(new ContainerParameterHandler($this->container)); + $parameterMiddlewarePipe->pipe(new InjectUserParameterHandler($authenticationService)); - $typeGenerator = new TypeGenerator($annotationReader, $namingStrategy, $typeRegistry, $this->container, $recursiveTypeMapper, $fieldsBuilder); - $inputTypeUtils = new InputTypeUtils($annotationReader, $namingStrategy); + $typeGenerator = new TypeGenerator($annotationReader, $namingStrategy, $typeRegistry, $this->container, $recursiveTypeMapper, $fieldsBuilder); + $inputTypeUtils = new InputTypeUtils($annotationReader, $namingStrategy); $inputTypeGenerator = new InputTypeGenerator($inputTypeUtils, $fieldsBuilder, $this->inputTypeValidator); foreach ($nsList as $ns) { @@ -456,7 +458,7 @@ public function createSchema(): Schema )); } - if (! empty($this->typeMapperFactories) || ! empty($this->queryProviderFactories)) { + if (!empty($this->typeMapperFactories) || !empty($this->queryProviderFactories)) { $context = new FactoryContext( $annotationReader, $typeResolver, diff --git a/tests/AbstractQueryProviderTest.php b/tests/AbstractQueryProviderTest.php index 9446affb95..0c888343f8 100644 --- a/tests/AbstractQueryProviderTest.php +++ b/tests/AbstractQueryProviderTest.php @@ -16,12 +16,14 @@ use Symfony\Component\Cache\Adapter\Psr16Adapter; use Symfony\Component\Cache\Psr16Cache; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use TheCodingMachine\GraphQLite\Containers\EmptyContainer; use TheCodingMachine\GraphQLite\Containers\LazyContainer; use TheCodingMachine\GraphQLite\Fixtures\Mocks\MockResolvableInputObjectType; use TheCodingMachine\GraphQLite\Fixtures\TestObject; use TheCodingMachine\GraphQLite\Fixtures\TestObject2; use TheCodingMachine\GraphQLite\Loggers\ExceptionLogger; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException; +use TheCodingMachine\GraphQLite\Mappers\Parameters\PrefetchParameterMiddleware; use TheCodingMachine\GraphQLite\Mappers\Parameters\ResolveInfoParameterHandler; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\BaseTypeMapper; @@ -40,6 +42,7 @@ use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewarePipe; use TheCodingMachine\GraphQLite\Mappers\Parameters\ParameterMiddlewarePipe; use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewarePipe; +use TheCodingMachine\GraphQLite\Middlewares\PrefetchFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\SecurityFieldMiddleware; use TheCodingMachine\GraphQLite\Reflection\CachedDocBlockFactory; use TheCodingMachine\GraphQLite\Security\SecurityExpressionLanguageProvider; @@ -76,9 +79,9 @@ protected function getTestObjectType(): MutableObjectType { if ($this->testObjectType === null) { $this->testObjectType = new MutableObjectType([ - 'name' => 'TestObject', - 'fields' => [ - 'test' => Type::string(), + 'name' => 'TestObject', + 'fields' => [ + 'test' => Type::string(), ], ]); } @@ -89,9 +92,9 @@ protected function getTestObjectType2(): MutableObjectType { if ($this->testObjectType2 === null) { $this->testObjectType2 = new MutableObjectType([ - 'name' => 'TestObject2', - 'fields' => [ - 'test' => Type::string(), + 'name' => 'TestObject2', + 'fields' => [ + 'test' => Type::string(), ], ]); } @@ -102,11 +105,11 @@ protected function getInputTestObjectType(): MockResolvableInputObjectType { if ($this->inputTestObjectType === null) { $this->inputTestObjectType = new MockResolvableInputObjectType([ - 'name' => 'TestObjectInput', - 'fields' => [ - 'test' => Type::string(), + 'name' => 'TestObjectInput', + 'fields' => [ + 'test' => Type::string(), ], - ], function($source, $args) { + ], function ($source, $args) { return new TestObject($args['test']); }); } @@ -119,7 +122,8 @@ protected function getTypeMapper() $arrayAdapter = new ArrayAdapter(); $arrayAdapter->setLogger(new ExceptionLogger()); - $this->typeMapper = new RecursiveTypeMapper(new class($this->getTestObjectType(), $this->getTestObjectType2(), $this->getInputTestObjectType()/*, $this->getInputTestObjectType2()*/) implements TypeMapperInterface { + $this->typeMapper = new RecursiveTypeMapper(new class($this->getTestObjectType(), $this->getTestObjectType2(), $this->getInputTestObjectType()/*, $this->getInputTestObjectType2()*/ + ) implements TypeMapperInterface { /** * @var ObjectType */ @@ -132,15 +136,17 @@ protected function getTypeMapper() * @var InputObjectType */ private $inputTestObjectType; + /** * @var InputObjectType */ public function __construct( - ObjectType $testObjectType, - ObjectType $testObjectType2, + ObjectType $testObjectType, + ObjectType $testObjectType2, InputObjectType $inputTestObjectType - ) { + ) + { $this->testObjectType = $testObjectType; $this->testObjectType2 = $testObjectType2; $this->inputTestObjectType = $inputTestObjectType; @@ -276,6 +282,7 @@ protected function buildFieldsBuilder(): FieldsBuilder $arrayAdapter = new ArrayAdapter(); $arrayAdapter->setLogger(new ExceptionLogger()); $psr16Cache = new Psr16Cache($arrayAdapter); + $container = new EmptyContainer(); $fieldMiddlewarePipe = new FieldMiddlewarePipe(); $fieldMiddlewarePipe->pipe(new AuthorizationFieldMiddleware( @@ -287,19 +294,16 @@ protected function buildFieldsBuilder(): FieldsBuilder new Psr16Adapter($psr16Cache), [new SecurityExpressionLanguageProvider()] ); - + $fieldMiddlewarePipe->pipe( new SecurityFieldMiddleware($expressionLanguage, - new VoidAuthenticationService(), - new VoidAuthorizationService()) + new VoidAuthenticationService(), + new VoidAuthorizationService()) ); $inputFieldMiddlewarePipe = new InputFieldMiddlewarePipe(); - $parameterMiddlewarePipe = new ParameterMiddlewarePipe(); - $parameterMiddlewarePipe->pipe(new ResolveInfoParameterHandler()); - - return new FieldsBuilder( + $fieldsBuilder = new FieldsBuilder( $this->getAnnotationReader(), $this->getTypeMapper(), $this->getArgumentResolver(), @@ -307,10 +311,16 @@ protected function buildFieldsBuilder(): FieldsBuilder new CachedDocBlockFactory($psr16Cache), new NamingStrategy(), $this->buildRootTypeMapper(), - $this->getParameterMiddlewarePipe(), + $parameterMiddlewarePipe, $fieldMiddlewarePipe, $inputFieldMiddlewarePipe ); + $parameterizedCallableResolver = new ParameterizedCallableResolver($fieldsBuilder, $container); + + $parameterMiddlewarePipe->pipe(new ResolveInfoParameterHandler()); + $parameterMiddlewarePipe->pipe(new PrefetchParameterMiddleware($parameterizedCallableResolver)); + + return $fieldsBuilder; } protected function getRootTypeMapper(): RootTypeMapperInterface diff --git a/tests/FieldsBuilderTest.php b/tests/FieldsBuilderTest.php index 9070a8fb90..e0941a9a18 100644 --- a/tests/FieldsBuilderTest.php +++ b/tests/FieldsBuilderTest.php @@ -48,10 +48,9 @@ use TheCodingMachine\GraphQLite\Fixtures\TestTypeMissingField; use TheCodingMachine\GraphQLite\Fixtures\TestTypeMissingReturnType; use TheCodingMachine\GraphQLite\Fixtures\TestTypeWithFailWith; -use TheCodingMachine\GraphQLite\Fixtures\TestTypeWithInvalidPrefetchParameter; use TheCodingMachine\GraphQLite\Fixtures\TestTypeWithMagicProperty; use TheCodingMachine\GraphQLite\Fixtures\TestTypeWithMagicPropertyType; -use TheCodingMachine\GraphQLite\Fixtures\TestTypeWithPrefetchMethod; +use TheCodingMachine\GraphQLite\Fixtures\TestTypeWithPrefetchMethods; use TheCodingMachine\GraphQLite\Fixtures\TestTypeWithSourceFieldInterface; use TheCodingMachine\GraphQLite\Fixtures\TestTypeWithSourceFieldInvalidParameterAnnotation; use TheCodingMachine\GraphQLite\Fixtures\TestSourceName; @@ -154,8 +153,7 @@ public function testMutations(): void public function testErrors(): void { - $controller = new class - { + $controller = new class { /** * @Query * @return string @@ -174,8 +172,7 @@ public function test($noTypeHint): string public function testTypeInDocBlock(): void { - $controller = new class - { + $controller = new class { /** * @Query * @param int $typeHintInDocBlock @@ -689,24 +686,13 @@ public function testInvalidPrefetchMethod(): void $queryProvider = $this->buildFieldsBuilder(); $this->expectException(InvalidPrefetchMethodRuntimeException::class); - $this->expectExceptionMessage('The @Field annotation in TheCodingMachine\\GraphQLite\\Fixtures\\TestTypeWithInvalidPrefetchMethod::test specifies a "prefetch method" that could not be found. Unable to find method TheCodingMachine\\GraphQLite\\Fixtures\\TestTypeWithInvalidPrefetchMethod::notExists.'); - $queryProvider->getFields($controller); - } - - public function testInvalidPrefetchParameter(): void - { - $controller = new TestTypeWithInvalidPrefetchParameter(); - - $queryProvider = $this->buildFieldsBuilder(); - - $this->expectException(InvalidPrefetchMethodRuntimeException::class); - $this->expectExceptionMessage('The @Field annotation in TheCodingMachine\GraphQLite\Fixtures\TestTypeWithInvalidPrefetchParameter::prefetch specifies a "prefetch method" but the data from the prefetch method is not gathered. The "prefetch" method should accept a second parameter that will contain data returned by the prefetch method.'); + $this->expectExceptionMessage('#[Prefetch] attribute on parameter $data in TheCodingMachine\\GraphQLite\\Fixtures\\TestTypeWithInvalidPrefetchMethod::test specifies a callable that is invalid: Method TheCodingMachine\\GraphQLite\\Fixtures\\TestTypeWithInvalidPrefetchMethod::notExists wasn\'t found or isn\'t accessible.'); $queryProvider->getFields($controller); } public function testPrefetchMethod(): void { - $controller = new TestTypeWithPrefetchMethod(); + $controller = new TestTypeWithPrefetchMethods(); $queryProvider = $this->buildFieldsBuilder(); @@ -715,9 +701,11 @@ public function testPrefetchMethod(): void $this->assertSame('test', $testField->name); - $this->assertCount(2, $testField->args); - $this->assertSame('string', $testField->args[0]->name); - $this->assertSame('int', $testField->args[1]->name); + $this->assertCount(4, $testField->args); + $this->assertSame('arg1', $testField->args[0]->name); + $this->assertSame('arg2', $testField->args[1]->name); + $this->assertSame('arg3', $testField->args[2]->name); + $this->assertSame('arg4', $testField->args[3]->name); } public function testSecurityBadQuery(): void diff --git a/tests/Fixtures/Integration/Models/Contact.php b/tests/Fixtures/Integration/Models/Contact.php index d6877f584c..58d12549dd 100644 --- a/tests/Fixtures/Integration/Models/Contact.php +++ b/tests/Fixtures/Integration/Models/Contact.php @@ -8,6 +8,7 @@ use TheCodingMachine\GraphQLite\Annotations\FailWith; use TheCodingMachine\GraphQLite\Annotations\HideIfUnauthorized; use TheCodingMachine\GraphQLite\Annotations\MagicField; +use TheCodingMachine\GraphQLite\Annotations\Prefetch; use TheCodingMachine\GraphQLite\Annotations\Security; use function array_search; use DateTimeInterface; @@ -212,9 +213,9 @@ public function setCompany(string $company): void } /** - * @Field(prefetchMethod="prefetchTheContacts") + * @Field() */ - public function repeatInnerName($data): string + public function repeatInnerName(#[Prefetch('prefetchTheContacts')] $data): string { $index = array_search($this, $data, true); if ($index === false) { @@ -223,7 +224,7 @@ public function repeatInnerName($data): string return $data[$index]->getName(); } - public function prefetchTheContacts(iterable $contacts) + public static function prefetchTheContacts(iterable $contacts) { return $contacts; } diff --git a/tests/Fixtures/Integration/Types/ContactType.php b/tests/Fixtures/Integration/Types/ContactType.php index aa817b3c5d..a347815879 100644 --- a/tests/Fixtures/Integration/Types/ContactType.php +++ b/tests/Fixtures/Integration/Types/ContactType.php @@ -3,6 +3,7 @@ namespace TheCodingMachine\GraphQLite\Fixtures\Integration\Types; +use TheCodingMachine\GraphQLite\Annotations\Prefetch; use function array_search; use function strtoupper; use TheCodingMachine\GraphQLite\Annotations\ExtendType; @@ -34,9 +35,9 @@ public function customField(Contact $contact, string $prefix): string } /** - * @Field(prefetchMethod="prefetchContacts") + * @Field() */ - public function repeatName(Contact $contact, $data, string $suffix): string + public function repeatName(Contact $contact, #[Prefetch('prefetchContacts')] $data, string $suffix): string { $index = array_search($contact, $data['contacts'], true); if ($index === false) { @@ -45,7 +46,7 @@ public function repeatName(Contact $contact, $data, string $suffix): string return $data['prefix'].$data['contacts'][$index]->getName().$suffix; } - public function prefetchContacts(iterable $contacts, string $prefix) + public static function prefetchContacts(iterable $contacts, string $prefix) { return [ 'contacts' => $contacts, diff --git a/tests/Fixtures/TestTypeWithInvalidPrefetchMethod.php b/tests/Fixtures/TestTypeWithInvalidPrefetchMethod.php index 1832d84f9b..41ffc5052f 100644 --- a/tests/Fixtures/TestTypeWithInvalidPrefetchMethod.php +++ b/tests/Fixtures/TestTypeWithInvalidPrefetchMethod.php @@ -5,6 +5,7 @@ use Exception; use TheCodingMachine\GraphQLite\Annotations\Field; +use TheCodingMachine\GraphQLite\Annotations\Prefetch; use TheCodingMachine\GraphQLite\Annotations\Query; use TheCodingMachine\GraphQLite\Annotations\Type; @@ -14,9 +15,9 @@ class TestTypeWithInvalidPrefetchMethod { /** - * @Field(prefetchMethod="notExists") + * @Field() */ - public function test(): string + public function test(object $source, #[Prefetch('notExists')] $data): string { return 'foo'; } diff --git a/tests/Fixtures/TestTypeWithInvalidPrefetchParameter.php b/tests/Fixtures/TestTypeWithInvalidPrefetchParameter.php deleted file mode 100644 index c2def0ff51..0000000000 --- a/tests/Fixtures/TestTypeWithInvalidPrefetchParameter.php +++ /dev/null @@ -1,28 +0,0 @@ -getInputTypeNameAndClassName(new ReflectionMethod($this, 'factoryNullableReturnType')); } + public function testToInputParameters(): void + { + if (Version::series() === '8.5') { + $this->markTestSkipped('Broken on PHPUnit 8.'); + } + + self::assertSame([], InputTypeUtils::toInputParameters([])); + self::assertSame([ + 'second' => $second = $this->createStub(InputTypeParameterInterface::class), + 'third' => $third = $this->createStub(InputTypeParameterInterface::class), + ], InputTypeUtils::toInputParameters([ + 'first' => new class ($second) implements ExpandsInputTypeParameters { + public function __construct( + private readonly ParameterInterface $second, + ) + { + } + + public function toInputTypeParameters(): array + { + return [ + 'second' => $this->second, + ]; + } + }, + 'third' => $third, + 'fourth' => $this->createStub(ParameterInterface::class), + ])); + } + public function factoryNoReturnType() { - + } public function factoryStringReturnType(): string diff --git a/tests/Integration/EndToEndTest.php b/tests/Integration/EndToEndTest.php index a8d5fb9bad..93d6f7f41f 100644 --- a/tests/Integration/EndToEndTest.php +++ b/tests/Integration/EndToEndTest.php @@ -39,6 +39,7 @@ use TheCodingMachine\GraphQLite\Mappers\Parameters\InjectUserParameterHandler; use TheCodingMachine\GraphQLite\Mappers\Parameters\ParameterMiddlewareInterface; use TheCodingMachine\GraphQLite\Mappers\Parameters\ParameterMiddlewarePipe; +use TheCodingMachine\GraphQLite\Mappers\Parameters\PrefetchParameterMiddleware; use TheCodingMachine\GraphQLite\Mappers\Parameters\ResolveInfoParameterHandler; use TheCodingMachine\GraphQLite\Mappers\PorpaginasTypeMapper; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapper; @@ -61,10 +62,12 @@ use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewareInterface; use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewarePipe; use TheCodingMachine\GraphQLite\Middlewares\MissingAuthorizationException; +use TheCodingMachine\GraphQLite\Middlewares\PrefetchFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\SecurityFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\SecurityInputFieldMiddleware; use TheCodingMachine\GraphQLite\NamingStrategy; use TheCodingMachine\GraphQLite\NamingStrategyInterface; +use TheCodingMachine\GraphQLite\ParameterizedCallableResolver; use TheCodingMachine\GraphQLite\QueryProviderInterface; use TheCodingMachine\GraphQLite\Reflection\CachedDocBlockFactory; use TheCodingMachine\GraphQLite\Schema; @@ -132,7 +135,8 @@ public function createContainer(array $overloadedServices = []): ContainerInterf return $queryProvider; }, FieldsBuilder::class => static function (ContainerInterface $container) { - return new FieldsBuilder( + $parameterMiddlewarePipe = $container->get(ParameterMiddlewareInterface::class); + $fieldsBuilder = new FieldsBuilder( $container->get(AnnotationReader::class), $container->get(RecursiveTypeMapperInterface::class), $container->get(ArgumentResolver::class), @@ -140,10 +144,15 @@ public function createContainer(array $overloadedServices = []): ContainerInterf $container->get(CachedDocBlockFactory::class), $container->get(NamingStrategyInterface::class), $container->get(RootTypeMapperInterface::class), - $container->get(ParameterMiddlewareInterface::class), + $parameterMiddlewarePipe, $container->get(FieldMiddlewareInterface::class), $container->get(InputFieldMiddlewareInterface::class), ); + $parameterizedCallableResolver = new ParameterizedCallableResolver($fieldsBuilder, $container); + + $parameterMiddlewarePipe->pipe(new PrefetchParameterMiddleware($parameterizedCallableResolver)); + + return $fieldsBuilder; }, FieldMiddlewareInterface::class => static function (ContainerInterface $container) { $pipe = new FieldMiddlewarePipe(); @@ -312,9 +321,9 @@ public function createContainer(array $overloadedServices = []): ContainerInterf // These are in reverse order of execution $errorRootTypeMapper = new FinalRootTypeMapper($container->get(RecursiveTypeMapperInterface::class)); $rootTypeMapper = new BaseTypeMapper($errorRootTypeMapper, $container->get(RecursiveTypeMapperInterface::class), $container->get(RootTypeMapperInterface::class)); - $rootTypeMapper = new MyCLabsEnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), new ArrayAdapter(), [ $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models') ]); + $rootTypeMapper = new MyCLabsEnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), new ArrayAdapter(), [$container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models')]); if (interface_exists(UnitEnum::class)) { - $rootTypeMapper = new EnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), new ArrayAdapter(), [ $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Models') ]); + $rootTypeMapper = new EnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), new ArrayAdapter(), [$container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Models')]); } $rootTypeMapper = new CompoundTypeMapper($rootTypeMapper, $container->get(RootTypeMapperInterface::class), $container->get(NamingStrategyInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class)); $rootTypeMapper = new IteratorTypeMapper($rootTypeMapper, $container->get(RootTypeMapperInterface::class)); @@ -385,7 +394,7 @@ public function createContainer(array $overloadedServices = []): ContainerInterf private function getSuccessResult(ExecutionResult $result, int $debugFlag = DebugFlag::RETHROW_INTERNAL_EXCEPTIONS): mixed { $array = $result->toArray($debugFlag); - if (isset($array['errors']) || ! isset($array['data'])) { + if (isset($array['errors']) || !isset($array['data'])) { $this->fail('Expected a successful answer. Got ' . json_encode($array, JSON_PRETTY_PRINT)); } return $array['data']; @@ -592,7 +601,7 @@ public function testPrefetchException(): void ); $this->expectException(GraphQLRuntimeException::class); - $this->expectExceptionMessage('When using "prefetch", you sure ensure that the GraphQL execution "context" (passed to the GraphQL::executeQuery method) is an instance of \\TheCodingMachine\\GraphQLite\\Context'); + $this->expectExceptionMessage('When using "prefetch", you should ensure that the GraphQL execution "context" (passed to the GraphQL::executeQuery method) is an instance of \\TheCodingMachine\\GraphQLite\\Context'); $result->toArray(DebugFlag::RETHROW_INTERNAL_EXCEPTIONS); } @@ -910,7 +919,7 @@ public function testEndToEndStaticFactories(): void ); $this->assertSame([ - 'echoFilters' => [ 'foo', 'bar', '12', '42', '62' ], + 'echoFilters' => ['foo', 'bar', '12', '42', '62'], ], $this->getSuccessResult($result)); // Call again to test GlobTypeMapper cache @@ -920,7 +929,7 @@ public function testEndToEndStaticFactories(): void ); $this->assertSame([ - 'echoFilters' => [ 'foo', 'bar', '12', '42', '62' ], + 'echoFilters' => ['foo', 'bar', '12', '42', '62'], ], $this->getSuccessResult($result)); } @@ -1309,7 +1318,7 @@ public function testEndToEndNativeEnums(): void ], ); $this->assertSame([ - 'singleEnum' => 'L', + 'singleEnum' => 'L', ], $this->getSuccessResult($result)); } @@ -1749,7 +1758,7 @@ public function getUser(): object|null public function testEndToEndInjectUserUnauthenticated(): void { $container = $this->createContainer([ - AuthenticationServiceInterface::class => static fn () => new VoidAuthenticationService(), + AuthenticationServiceInterface::class => static fn() => new VoidAuthenticationService(), ]); $schema = $container->get(Schema::class); @@ -1803,7 +1812,7 @@ public function testNullableResult(): void $queryString, ); $resultArray = $result->toArray(DebugFlag::RETHROW_UNSAFE_EXCEPTIONS); - if (isset($resultArray['errors']) || ! isset($resultArray['data'])) { + if (isset($resultArray['errors']) || !isset($resultArray['data'])) { $this->fail('Expected a successful answer. Got ' . json_encode($resultArray, JSON_PRETTY_PRINT)); } $this->assertNull($resultArray['data']['nullableResult']); @@ -2321,7 +2330,7 @@ public function isAllowed(string $right, $subject = null): bool $queryString, ); - $this->assertSame('Access denied.', $result->toArray(DebugFlag::RETHROW_UNSAFE_EXCEPTIONS)['errors'][0]['message']); + $this->assertSame('Access denied.', $result->toArray(DebugFlag::RETHROW_UNSAFE_EXCEPTIONS)['errors'][0]['message']); $container = $this->createContainer([ AuthenticationServiceInterface::class => static function () { diff --git a/tests/Mappers/Parameters/HardCodedParameter.php b/tests/Mappers/Parameters/HardCodedParameter.php index 179c258a6a..be2f18ba64 100644 --- a/tests/Mappers/Parameters/HardCodedParameter.php +++ b/tests/Mappers/Parameters/HardCodedParameter.php @@ -9,7 +9,7 @@ class HardCodedParameter implements ParameterInterface { - public function __construct(private mixed $value) + public function __construct(private mixed $value = null) { } diff --git a/tests/Mappers/Parameters/PrefetchParameterMiddlewareTest.php b/tests/Mappers/Parameters/PrefetchParameterMiddlewareTest.php new file mode 100644 index 0000000000..ea749d6de8 --- /dev/null +++ b/tests/Mappers/Parameters/PrefetchParameterMiddlewareTest.php @@ -0,0 +1,110 @@ +createMock(ParameterHandlerInterface::class); + $next->method('mapParameter')->willReturn($expected); + + $refMethod = new ReflectionMethod(__CLASS__, 'dummy'); + $parameter = $refMethod->getParameters()[0]; + + $result = (new PrefetchParameterMiddleware( + $this->createMock(ParameterizedCallableResolver::class), + ))->mapParameter( + $parameter, + new DocBlock(), + null, + new ParameterAnnotations([]), + $next, + ); + + self::assertSame($expected, $result); + } + + public function testMapsToPrefetchDataParameter(): void + { + $parameterizedCallableResolver = $this->createMock(ParameterizedCallableResolver::class); + $parameterizedCallableResolver + ->method('resolve') + ->with('dummy', new IsEqual(new ReflectionClass(self::class)), 1) + ->willReturn([ + fn() => null, + [], + ]); + + $refMethod = new ReflectionMethod(__CLASS__, 'dummy'); + $parameter = $refMethod->getParameters()[0]; + + $result = (new PrefetchParameterMiddleware( + $parameterizedCallableResolver, + ))->mapParameter( + $parameter, + new DocBlock(), + null, + new ParameterAnnotations([ + new Prefetch('dummy'), + ]), + $this->createMock(ParameterHandlerInterface::class), + ); + + self::assertInstanceOf(PrefetchDataParameter::class, $result); + } + + public function testRethrowsInvalidCallableAsInvalidPrefetchException(): void + { + $this->expectException(InvalidPrefetchMethodRuntimeException::class); + $this->expectExceptionMessage('#[Prefetch] attribute on parameter $foo in TheCodingMachine\\GraphQLite\\Mappers\\Parameters\\PrefetchParameterMiddlewareTest::dummy specifies a callable that is invalid: Method TheCodingMachine\\GraphQLite\\Fixtures\\TestType::notExists wasn\'t found or isn\'t accessible.'); + + $parameterizedCallableResolver = $this->createMock(ParameterizedCallableResolver::class); + $parameterizedCallableResolver + ->method('resolve') + ->with([TestType::class, 'notExists'], new IsEqual(new ReflectionClass(self::class)), 1) + ->willThrowException(InvalidCallableRuntimeException::methodNotFound(TestType::class, 'notExists')); + + $refMethod = new ReflectionMethod(__CLASS__, 'dummy'); + $parameter = $refMethod->getParameters()[0]; + + (new PrefetchParameterMiddleware( + $parameterizedCallableResolver, + ))->mapParameter( + $parameter, + new DocBlock(), + null, + new ParameterAnnotations([ + new Prefetch([TestType::class, 'notExists']), + ]), + $this->createMock(ParameterHandlerInterface::class), + ); + } + + private function dummy($foo) + { + + } +} diff --git a/tests/ParameterizedCallableResolverTest.php b/tests/ParameterizedCallableResolverTest.php new file mode 100644 index 0000000000..dbe8eeb086 --- /dev/null +++ b/tests/ParameterizedCallableResolverTest.php @@ -0,0 +1,85 @@ +createStub(ParameterInterface::class)]; + + $fieldsBuilder = $this->createMock(FieldsBuilder::class); + $fieldsBuilder->method('getParameters') + ->with(new IsEqual(new \ReflectionMethod(Contact::class, 'prefetchTheContacts')), 123) + ->willReturn($expectedParameters); + + [$resultingCallable, $resultingParameters] = (new ParameterizedCallableResolver( + $fieldsBuilder, + $this->createMock(ContainerInterface::class), + ))->resolve([Contact::class, 'prefetchTheContacts'], self::class, 123); + + self::assertSame(['test'], $resultingCallable(['test'])); + self::assertSame($expectedParameters, $resultingParameters); + } + + public function testResolveReturnsCallableAndParametersFromStaticMethodOnSelf(): void + { + $expectedParameters = [$this->createStub(ParameterInterface::class)]; + + $fieldsBuilder = $this->createMock(FieldsBuilder::class); + $fieldsBuilder->method('getParameters') + ->with(new IsEqual(new \ReflectionMethod(Contact::class, 'prefetchTheContacts')), 123) + ->willReturn($expectedParameters); + + [$resultingCallable, $resultingParameters] = (new ParameterizedCallableResolver( + $fieldsBuilder, + $this->createMock(ContainerInterface::class), + ))->resolve('prefetchTheContacts', Contact::class, 123); + + self::assertSame(['test'], $resultingCallable(['test'])); + self::assertSame($expectedParameters, $resultingParameters); + } + + public function testResolveReturnsCallableAndParametersFromContainer(): void + { + $expectedParameters = [$this->createStub(ParameterInterface::class)]; + + $fieldsBuilder = $this->createMock(FieldsBuilder::class); + $fieldsBuilder->method('getParameters') + ->with(new IsEqual(new \ReflectionMethod(FooExtendType::class, 'customExtendedField')), 123) + ->willReturn($expectedParameters); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get') + ->with(FooExtendType::class) + ->willReturn(new FooExtendType()); + + [$resultingCallable, $resultingParameters] = (new ParameterizedCallableResolver( + $fieldsBuilder, + $container, + ))->resolve([FooExtendType::class, 'customExtendedField'], self::class, 123); + + self::assertSame('TEST', $resultingCallable(new TestObject('test'))); + self::assertSame($expectedParameters, $resultingParameters); + } + + public function testResolveThrowsInvalidCallableMethodNotFoundException(): void + { + $this->expectException(InvalidCallableRuntimeException::class); + $this->expectExceptionMessage('Method TheCodingMachine\\GraphQLite\\ParameterizedCallableResolverTest::doesntExist wasn\'t found or isn\'t accessible.'); + + (new ParameterizedCallableResolver( + $this->createMock(FieldsBuilder::class), + $this->createMock(ContainerInterface::class), + ))->resolve('doesntExist', self::class); + } +} \ No newline at end of file diff --git a/tests/Parameters/PrefetchDataParameterTest.php b/tests/Parameters/PrefetchDataParameterTest.php new file mode 100644 index 0000000000..9d241b7ccc --- /dev/null +++ b/tests/Parameters/PrefetchDataParameterTest.php @@ -0,0 +1,86 @@ +fail('Should not be called'); + }, []); + + $source = new stdClass(); + $prefetchResult = new stdClass(); + $context = new Context(); + $args = [ + 'first' => 'qwe', + 'second' => 'rty' + ]; + $buffer = $context->getPrefetchBuffer($parameter); + + $buffer->storeResult($prefetchResult, $args); + + $resolvedParameterPromise = $parameter->resolve($source, $args, $context, $this->createStub(ResolveInfo::class)); + + self::assertSame([$source], $buffer->getObjectsByArguments($args)); + self::assertSame($prefetchResult, $this->deferredValue($resolvedParameterPromise)); + } + + public function testResolveWithoutExistingResult(): void + { + $prefetchResult = new stdClass(); + $source = new stdClass(); + $prefetchHandler = function (array $sources, string $second) use ($prefetchResult, $source) { + self::assertSame([$source], $sources); + self::assertSame('rty', $second); + + return $prefetchResult; + }; + + $parameter = new PrefetchDataParameter('field', $prefetchHandler, [ + new InputTypeParameter( + name: 'second', + type: Type::string(), + description: '', + hasDefaultValue: false, + defaultValue: null, + argumentResolver: new ArgumentResolver() + ) + ]); + + $context = new Context(); + $args = [ + 'first' => 'qwe', + 'second' => 'rty', + ]; + $buffer = $context->getPrefetchBuffer($parameter); + + $resolvedParameterPromise = $parameter->resolve($source, $args, $context, $this->createStub(ResolveInfo::class)); + + self::assertFalse($buffer->hasResult($args)); + self::assertSame([$source], $buffer->getObjectsByArguments($args)); + self::assertSame($prefetchResult, $this->deferredValue($resolvedParameterPromise)); + self::assertTrue($buffer->hasResult($args)); + } + + private function deferredValue(Deferred $promise): mixed + { + $syncPromiseAdapter = new SyncPromiseAdapter(); + + return $syncPromiseAdapter->wait(new Promise($promise, $syncPromiseAdapter)); + } +} \ No newline at end of file diff --git a/tests/QueryFieldTest.php b/tests/QueryFieldTest.php index 278c31b586..2503c63351 100644 --- a/tests/QueryFieldTest.php +++ b/tests/QueryFieldTest.php @@ -24,7 +24,7 @@ public function resolve(?object $source, array $args, mixed $context, ResolveInf throw new Error('boum'); } }, - ], $sourceResolver, $sourceResolver, null, null, null, []); + ], $sourceResolver, $sourceResolver, null, null, []); $resolve = $queryField->resolveFn; diff --git a/website/docs/annotations-reference.md b/website/docs/annotations-reference.md index 9dddfe0aff..334b2864d9 100644 --- a/website/docs/annotations-reference.md +++ b/website/docs/annotations-reference.md @@ -119,6 +119,17 @@ annotations | *no* | array\ | A set of annotations that (*) **Note**: `outputType` and `phpType` are mutually exclusive. You MUST provide one of them. +## @Prefetch + +Marks field parameter to be used for [prefetching](prefetch-method.mdx). + +**Applies on**: parameters of methods annotated with `@Query`, `@Mutation` or `@Field`. + +Attribute | Compulsory | Type | Definition +------------------------------|------------|----------|-------- +callable | *no* | callable | Name of the prefetch method (in same class) or a full callable, either a static method or regular service from the container + + ## @Logged The `@Logged` annotation is used to declare a Query/Mutation/Field is only visible to logged users. diff --git a/website/docs/field-middlewares.md b/website/docs/field-middlewares.md index 8532a40688..18eac15b30 100644 --- a/website/docs/field-middlewares.md +++ b/website/docs/field-middlewares.md @@ -43,10 +43,6 @@ class QueryFieldDescriptor public function withType($type): self { /* ... */ } public function getParameters(): array { /* ... */ } public function withParameters(array $parameters): self { /* ... */ } - public function getPrefetchParameters(): array { /* ... */ } - public function withPrefetchParameters(array $prefetchParameters): self { /* ... */ } - public function getPrefetchMethodName(): ?string { /* ... */ } - public function withPrefetchMethodName(?string $prefetchMethodName): self { /* ... */ } public function withCallable(callable $callable): self { /* ... */ } public function withTargetMethodOnSource(?string $targetMethodOnSource): self { /* ... */ } public function isInjectSource(): bool { /* ... */ } diff --git a/website/docs/prefetch-method.mdx b/website/docs/prefetch-method.mdx index 9df8877837..8c3717178b 100644 --- a/website/docs/prefetch-method.mdx +++ b/website/docs/prefetch-method.mdx @@ -59,8 +59,8 @@ class PostType { * @param mixed $prefetchedUsers * @return User */ - #[Field(prefetchMethod: "prefetchUsers")] - public function getUser(Post $post, $prefetchedUsers): User + #[Field] + public function getUser(Post $post, #[Prefetch("prefetchUsers")] $prefetchedUsers): User { // This method will receive the $prefetchedUsers as second argument. This is the return value of the "prefetchUsers" method below. // Using this prefetched list, it should be easy to map it to the post @@ -70,7 +70,7 @@ class PostType { * @param Post[] $posts * @return mixed */ - public function prefetchUsers(iterable $posts) + public static function prefetchUsers(iterable $posts) { // This function is called only once per GraphQL request // with the list of posts. You can fetch the list of users @@ -120,13 +120,16 @@ class PostType { -When the "prefetchMethod" attribute is detected in the "@Field" annotation, the method is called automatically. -The first argument of the method is an array of instances of the main type. -The "prefetchMethod" can return absolutely anything (mixed). The return value will be passed as the second parameter of the "@Field" annotated method. +When a "#[Prefetch]" attribute is detected on a parameter of "@Field" annotation, the method is called automatically. +The prefetch callable must be one of the following: + - a static method in the same class: `#[Prefetch('prefetchMethod')]` + - a static method in a different class: `#[Prefetch([OtherClass::class, 'prefetchMethod')]` + - a non-static method in a different class, resolvable through the container: `#[Prefetch([OtherService::class, 'prefetchMethod'])]` +The first argument of the method is always an array of instances of the main type. It can return absolutely anything (mixed). ## Input arguments -Field arguments can be set either on the @Field annotated method OR/AND on the prefetchMethod. +Field arguments can be set either on the @Field annotated method OR/AND on the prefetch methods. For instance: @@ -146,8 +149,8 @@ class PostType { * @param mixed $prefetchedComments * @return Comment[] */ - #[Field(prefetchMethod: "prefetchComments")] - public function getComments(Post $post, $prefetchedComments): array + #[Field] + public function getComments(Post $post, #[Prefetch("prefetchComments")] $prefetchedComments): array { // ... } @@ -156,7 +159,7 @@ class PostType { * @param Post[] $posts * @return mixed */ - public function prefetchComments(iterable $posts, bool $hideSpam, int $filterByScore) + public static function prefetchComments(iterable $posts, bool $hideSpam, int $filterByScore) { // Parameters passed after the first parameter (hideSpam, filterByScore...) are automatically exposed // as GraphQL arguments for the "comments" field. @@ -197,5 +200,3 @@ class PostType { - -The prefetch method MUST be in the same class as the @Field-annotated method and MUST be public. From 7b41bc906a27ebbc20c73b37a7662386d93ed75d Mon Sep 17 00:00:00 2001 From: oprypkhantc <54406427+oprypkhantc@users.noreply.github.com> Date: Fri, 4 Aug 2023 17:23:00 +0300 Subject: [PATCH 026/108] Apollo Automatic persisted queries (#611) * Automatic persisted queries with docs * Docs for APQ * Make sure PersistedQueryError is not classified as RequestError so it's returned as code 200 due to Apollo * Fix response codes for persisted query errors * Make persisted query errors at least extend GraphQL errors so they are treated as such * Fix PHPStan errors * Code style --- src/Http/Psr15GraphQLMiddlewareBuilder.php | 14 ++++ .../CachePersistedQueryLoader.php | 54 +++++++++++++++ .../NotSupportedPersistedQueryLoader.php | 19 ++++++ .../PersistedQueryException.php | 12 ++++ .../PersistedQueryIdInvalidException.php | 34 ++++++++++ .../PersistedQueryNotFoundException.php | 34 ++++++++++ .../PersistedQueryNotSupportedException.php | 34 ++++++++++ .../CachePersistedQueryLoaderTest.php | 66 +++++++++++++++++++ .../NotSupportedPersistedQueryLoaderTest.php | 19 ++++++ website/docs/automatic-persisted-queries.mdx | 61 +++++++++++++++++ website/docs/other-frameworks.mdx | 4 ++ website/sidebars.json | 3 +- 12 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 src/Server/PersistedQuery/CachePersistedQueryLoader.php create mode 100644 src/Server/PersistedQuery/NotSupportedPersistedQueryLoader.php create mode 100644 src/Server/PersistedQuery/PersistedQueryException.php create mode 100644 src/Server/PersistedQuery/PersistedQueryIdInvalidException.php create mode 100644 src/Server/PersistedQuery/PersistedQueryNotFoundException.php create mode 100644 src/Server/PersistedQuery/PersistedQueryNotSupportedException.php create mode 100644 tests/Server/PersistedQuery/CachePersistedQueryLoaderTest.php create mode 100644 tests/Server/PersistedQuery/NotSupportedPersistedQueryLoaderTest.php create mode 100644 website/docs/automatic-persisted-queries.mdx diff --git a/src/Http/Psr15GraphQLMiddlewareBuilder.php b/src/Http/Psr15GraphQLMiddlewareBuilder.php index 5db5ae15c2..0ee4565771 100644 --- a/src/Http/Psr15GraphQLMiddlewareBuilder.php +++ b/src/Http/Psr15GraphQLMiddlewareBuilder.php @@ -4,6 +4,7 @@ namespace TheCodingMachine\GraphQLite\Http; +use DateInterval; use GraphQL\Error\DebugFlag; use GraphQL\Server\ServerConfig; use GraphQL\Type\Schema; @@ -12,14 +13,19 @@ use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Server\MiddlewareInterface; +use Psr\SimpleCache\CacheInterface; use TheCodingMachine\GraphQLite\Context\Context; use TheCodingMachine\GraphQLite\Exceptions\WebonyxErrorHandler; use TheCodingMachine\GraphQLite\GraphQLRuntimeException; +use TheCodingMachine\GraphQLite\Server\PersistedQuery\CachePersistedQueryLoader; +use TheCodingMachine\GraphQLite\Server\PersistedQuery\NotSupportedPersistedQueryLoader; use function class_exists; /** * A factory generating a PSR-15 middleware tailored for GraphQLite. + * + * @phpstan-import-type PersistedQueryLoader from ServerConfig */ class Psr15GraphQLMiddlewareBuilder { @@ -40,6 +46,7 @@ public function __construct(Schema $schema) $this->config->setErrorFormatter([WebonyxErrorHandler::class, 'errorFormatter']); $this->config->setErrorsHandler([WebonyxErrorHandler::class, 'errorHandler']); $this->config->setContext(new Context()); + $this->config->setPersistedQueryLoader(new NotSupportedPersistedQueryLoader()); $this->httpCodeDecider = new HttpCodeDecider(); } @@ -83,6 +90,13 @@ public function setHttpCodeDecider(HttpCodeDeciderInterface $httpCodeDecider): s return $this; } + public function useAutomaticPersistedQueries(CacheInterface $cache, DateInterval|null $ttl = null): self + { + $this->config->setPersistedQueryLoader(new CachePersistedQueryLoader($cache, $ttl)); + + return $this; + } + public function createMiddleware(): MiddlewareInterface { if ($this->responseFactory === null && ! class_exists(ResponseFactory::class)) { diff --git a/src/Server/PersistedQuery/CachePersistedQueryLoader.php b/src/Server/PersistedQuery/CachePersistedQueryLoader.php new file mode 100644 index 0000000000..1039f63be1 --- /dev/null +++ b/src/Server/PersistedQuery/CachePersistedQueryLoader.php @@ -0,0 +1,54 @@ +cache->get($queryId); + + if ($query) { + return $query; + } + + $query = $operation->query; + + if (! $query) { + throw new PersistedQueryNotFoundException(); + } + + if (! $this->queryMatchesId($queryId, $query)) { + throw new PersistedQueryIdInvalidException(); + } + + $this->cache->set($queryId, $query, $this->ttl); + + return $query; + } + + private function queryMatchesId(string $queryId, string $query): bool + { + return $queryId === hash('sha256', $query); + } +} diff --git a/src/Server/PersistedQuery/NotSupportedPersistedQueryLoader.php b/src/Server/PersistedQuery/NotSupportedPersistedQueryLoader.php new file mode 100644 index 0000000000..d27052223e --- /dev/null +++ b/src/Server/PersistedQuery/NotSupportedPersistedQueryLoader.php @@ -0,0 +1,19 @@ +code = 'PERSISTED_QUERY_ID_INVALID'; + } + + /** @return array */ + public function getExtensions(): array + { + return [ + 'code' => $this->code, + ]; + } + + public function isClientSafe(): bool + { + return true; + } +} diff --git a/src/Server/PersistedQuery/PersistedQueryNotFoundException.php b/src/Server/PersistedQuery/PersistedQueryNotFoundException.php new file mode 100644 index 0000000000..473d1dad9a --- /dev/null +++ b/src/Server/PersistedQuery/PersistedQueryNotFoundException.php @@ -0,0 +1,34 @@ +code = 'PERSISTED_QUERY_NOT_FOUND'; + } + + /** @return array */ + public function getExtensions(): array + { + return [ + 'code' => $this->code, + ]; + } + + public function isClientSafe(): bool + { + return true; + } +} diff --git a/src/Server/PersistedQuery/PersistedQueryNotSupportedException.php b/src/Server/PersistedQuery/PersistedQueryNotSupportedException.php new file mode 100644 index 0000000000..b9ee5d8b9a --- /dev/null +++ b/src/Server/PersistedQuery/PersistedQueryNotSupportedException.php @@ -0,0 +1,34 @@ +code = 'PERSISTED_QUERY_NOT_SUPPORTED'; + } + + /** @return array */ + public function getExtensions(): array + { + return [ + 'code' => $this->code, + ]; + } + + public function isClientSafe(): bool + { + return true; + } +} diff --git a/tests/Server/PersistedQuery/CachePersistedQueryLoaderTest.php b/tests/Server/PersistedQuery/CachePersistedQueryLoaderTest.php new file mode 100644 index 0000000000..d4058f4bcf --- /dev/null +++ b/tests/Server/PersistedQuery/CachePersistedQueryLoaderTest.php @@ -0,0 +1,66 @@ +cache = new Psr16Cache(new ArrayAdapter()); + } + + public function testReturnsQueryFromCache(): void + { + $loader = new CachePersistedQueryLoader($this->cache); + + $this->cache->set(self::QUERY_HASH, self::QUERY_STRING); + + self::assertSame(self::QUERY_STRING, $loader(self::QUERY_HASH, OperationParams::create([]))); + self::assertSame(self::QUERY_STRING, $loader(strtoupper(self::QUERY_HASH), OperationParams::create([]))); + } + + public function testSavesQueryIntoCache(): void + { + $loader = new CachePersistedQueryLoader($this->cache); + + self::assertSame(self::QUERY_STRING, $loader(self::QUERY_HASH, OperationParams::create([ + 'query' => self::QUERY_STRING, + ]))); + self::assertTrue($this->cache->has(self::QUERY_HASH)); + self::assertSame(self::QUERY_STRING, $this->cache->get(self::QUERY_HASH)); + } + + public function testThrowsNotFoundExceptionWhenQueryNotFound(): void + { + $this->expectException(PersistedQueryNotFoundException::class); + $this->expectExceptionMessage('Persisted query by that ID was not found and "query" was omitted.'); + + $loader = new CachePersistedQueryLoader($this->cache); + + $loader('asd', OperationParams::create([])); + } + + public function testThrowsIdInvalidExceptionWhenQueryDoesNotMatchId(): void + { + $this->expectException(PersistedQueryIdInvalidException::class); + $this->expectExceptionMessage('Persisted query by that ID doesnt match the provided query; you are likely incorrectly hashing your query.'); + + $loader = new CachePersistedQueryLoader($this->cache); + + $loader('asd', OperationParams::create([ + 'query' => self::QUERY_STRING + ])); + } +} \ No newline at end of file diff --git a/tests/Server/PersistedQuery/NotSupportedPersistedQueryLoaderTest.php b/tests/Server/PersistedQuery/NotSupportedPersistedQueryLoaderTest.php new file mode 100644 index 0000000000..03c6323ccd --- /dev/null +++ b/tests/Server/PersistedQuery/NotSupportedPersistedQueryLoaderTest.php @@ -0,0 +1,19 @@ +expectException(PersistedQueryNotSupportedException::class); + $this->expectExceptionMessage('Persisted queries are not supported by this server.'); + + $loader = new NotSupportedPersistedQueryLoader(); + + $loader('asd', OperationParams::create([])); + } +} \ No newline at end of file diff --git a/website/docs/automatic-persisted-queries.mdx b/website/docs/automatic-persisted-queries.mdx new file mode 100644 index 0000000000..f505a869f4 --- /dev/null +++ b/website/docs/automatic-persisted-queries.mdx @@ -0,0 +1,61 @@ +--- +id: automatic-persisted-queries +title: Automatic persisted queries +sidebar_label: Automatic persisted queries +--- + +## The problem + +Clients send queries to GraphQLite as HTTP requests that include the GraphQL string of the query to execute. +Depending on your graph's schema, the size of a valid query string might be arbitrarily large. +As query strings become larger, increased latency and network usage can noticeably degrade client performance. + +To combat this, GraphQL servers use a technique called "persisted queries". The basic idea is instead of +sending the whole query string, clients only send it's unique identifier. The server then finds the actual +query string by given identifier and use that as if the client sent the whole query in the first place. +That helps improve GraphQL network performance with zero build-time configuration by sending smaller GraphQL HTTP requests. +A smaller request payload reduces bandwidth utilization and speeds up GraphQL Client loading times. + +## Apollo APQ + +[Automatic persisted queries (APQ) is technique created by Apollo](https://www.apollographql.com/docs/apollo-server/performance/apq/) +and is aimed to implement a simple automatic way of persisting queries. Queries are cached on the server side, +along with its unique identifier (always its SHA-256 hash). Clients can send this identifier instead of the +corresponding query string, thus reducing request sizes dramatically (response sizes are unaffected). + +To persist a query string, GraphQLite server must first receive it from a requesting client. +Consequently, each unique query string must be sent to Apollo Server at least once. +After any client sends a query string to persist, every client that executes that query can then benefit from APQ. + +```mermaid +sequenceDiagram; + Client app->>GraphQL Server: Sends SHA-256 hash of query string to execute + Note over GraphQL Server: Fails to find persisted query string + GraphQL Server->>Client app: Responds with error + Client app->>GraphQL Server: Sends both query string AND hash + Note over GraphQL Server: Persists query string and hash + GraphQL Server->>Client app: Executes query and returns result + Note over Client app: Time passes + Client app->>GraphQL Server: Sends SHA-256 hash of query string to execute + Note over GraphQL Server: Finds persisted query string + GraphQL Server->>Client app: Executes query and returns result +``` + +Persisted queries are especially effective when clients send queries as GET requests. +This enables clients to take advantage of the browser cache and integrate with a CDN. + +Because query identifiers are deterministic hashes, clients can generate them at runtime. No additional build steps are required. + +## Setup + +To use Automatic persisted queries with GraphQLite, you may use +`useAutomaticPersistedQueries` method when building your PSR-15 middleware: + +```php +$builder = new Psr15GraphQLMiddlewareBuilder($schema); + +// You need to provide a PSR compatible cache and a TTL for queries. The best cache would be some kind +// of in-memory cache with a limit on number of entries to make sure your cache can't be maliciously spammed with queries. +$builder->useAutomaticPersistedQueries($cache, new DateInterval('PT1H')); +``` + diff --git a/website/docs/other-frameworks.mdx b/website/docs/other-frameworks.mdx index 0685d4f83a..71cc9de3aa 100644 --- a/website/docs/other-frameworks.mdx +++ b/website/docs/other-frameworks.mdx @@ -164,6 +164,10 @@ $builder->setConfig($config); $builder->setResponseFactory(new ResponseFactory()); // Set a PSR-18 ResponseFactory (not needed if you are using zend-framework/zend-diactoros ^2 $builder->setStreamFactory(new StreamFactory()); // Set a PSR-18 StreamFactory (not needed if you are using zend-framework/zend-diactoros ^2 $builder->setHttpCodeDecider(new HttpCodeDecider()); // Set a class in charge of deciding the HTTP status code based on the response. + +// Configure the server to use Apollo automatic persisted queries with given cache and an optional time-to-live. +// See https://www.apollographql.com/docs/apollo-server/performance/apq/ +$builder->useAutomaticPersistedQueries($cache, new DateInterval('PT1H')); ``` ### Example diff --git a/website/sidebars.json b/website/sidebars.json index f9b6ef974f..10bea7aae7 100755 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -29,7 +29,8 @@ ], "Performance": [ "query-plan", - "prefetch-method" + "prefetch-method", + "automatic-persisted-queries" ], "Advanced": [ "file-uploads", From 29767845235fe0ede125984e2dcb619c95ddea19 Mon Sep 17 00:00:00 2001 From: mshapovalov <46743019+mshapovalov@users.noreply.github.com> Date: Thu, 31 Aug 2023 09:09:51 +0200 Subject: [PATCH 027/108] issue #615 prefetchMethod documentation fix (#616) * issue #615 prefetchMethod documentation fix * issue #615 prefetchMethod previous versions documentation fix --- website/docs/prefetch-method.mdx | 16 ++++++---------- .../version-3.0/prefetch_method.mdx | 12 ++++-------- .../version-4.0/prefetch_method.mdx | 6 ++---- .../version-4.1/prefetch_method.mdx | 12 ++++-------- .../version-4.2/prefetch-method.mdx | 12 ++++-------- .../version-4.3/prefetch-method.mdx | 12 ++++-------- .../version-5.0/prefetch-method.mdx | 12 ++++-------- .../version-6.0/prefetch-method.mdx | 12 ++++-------- .../version-6.1/prefetch-method.mdx | 6 ++---- 9 files changed, 34 insertions(+), 66 deletions(-) diff --git a/website/docs/prefetch-method.mdx b/website/docs/prefetch-method.mdx index 8c3717178b..1c24e7099f 100644 --- a/website/docs/prefetch-method.mdx +++ b/website/docs/prefetch-method.mdx @@ -55,14 +55,13 @@ Instead, GraphQLite offers an easier to implement solution: the ability to fetch #[Type] class PostType { /** - * @param Post $post * @param mixed $prefetchedUsers * @return User */ #[Field] - public function getUser(Post $post, #[Prefetch("prefetchUsers")] $prefetchedUsers): User + public function getUser(#[Prefetch("prefetchUsers")] $prefetchedUsers): User { - // This method will receive the $prefetchedUsers as second argument. This is the return value of the "prefetchUsers" method below. + // This method will receive the $prefetchedUsers as first argument. This is the return value of the "prefetchUsers" method below. // Using this prefetched list, it should be easy to map it to the post } @@ -91,13 +90,12 @@ class PostType { class PostType { /** * @Field(prefetchMethod="prefetchUsers") - * @param Post $post * @param mixed $prefetchedUsers * @return User */ - public function getUser(Post $post, $prefetchedUsers): User + public function getUser($prefetchedUsers): User { - // This method will receive the $prefetchedUsers as second argument. This is the return value of the "prefetchUsers" method below. + // This method will receive the $prefetchedUsers as first argument. This is the return value of the "prefetchUsers" method below. // Using this prefetched list, it should be easy to map it to the post } @@ -145,12 +143,11 @@ For instance: #[Type] class PostType { /** - * @param Post $post * @param mixed $prefetchedComments * @return Comment[] */ #[Field] - public function getComments(Post $post, #[Prefetch("prefetchComments")] $prefetchedComments): array + public function getComments(#[Prefetch("prefetchComments")] $prefetchedComments): array { // ... } @@ -177,11 +174,10 @@ class PostType { class PostType { /** * @Field(prefetchMethod="prefetchComments") - * @param Post $post * @param mixed $prefetchedComments * @return Comment[] */ - public function getComments(Post $post, $prefetchedComments): array + public function getComments($prefetchedComments): array { // ... } diff --git a/website/versioned_docs/version-3.0/prefetch_method.mdx b/website/versioned_docs/version-3.0/prefetch_method.mdx index 5d4947320a..b118b33cca 100644 --- a/website/versioned_docs/version-3.0/prefetch_method.mdx +++ b/website/versioned_docs/version-3.0/prefetch_method.mdx @@ -55,12 +55,11 @@ Instead, GraphQLite offers an easier to implement solution: the ability to fetch #[Type] class PostType { /** - * @param Post $post * @param mixed $prefetchedUsers * @return User */ #[Field(prefetchMethod: "prefetchUsers")] - public function getUser(Post $post, $prefetchedUsers): User + public function getUser($prefetchedUsers): User { // This method will receive the $prefetchedUsers as second argument. This is the return value of the "prefetchUsers" method below. // Using this prefetched list, it should be easy to map it to the post @@ -91,11 +90,10 @@ class PostType { class PostType { /** * @Field(prefetchMethod="prefetchUsers") - * @param Post $post * @param mixed $prefetchedUsers * @return User */ - public function getUser(Post $post, $prefetchedUsers): User + public function getUser($prefetchedUsers): User { // This method will receive the $prefetchedUsers as second argument. This is the return value of the "prefetchUsers" method below. // Using this prefetched list, it should be easy to map it to the post @@ -142,12 +140,11 @@ For instance: #[Type] class PostType { /** - * @param Post $post * @param mixed $prefetchedComments * @return Comment[] */ #[Field(prefetchMethod: "prefetchComments")] - public function getComments(Post $post, $prefetchedComments): array + public function getComments($prefetchedComments): array { // ... } @@ -174,11 +171,10 @@ class PostType { class PostType { /** * @Field(prefetchMethod="prefetchComments") - * @param Post $post * @param mixed $prefetchedComments * @return Comment[] */ - public function getComments(Post $post, $prefetchedComments): array + public function getComments($prefetchedComments): array { // ... } diff --git a/website/versioned_docs/version-4.0/prefetch_method.mdx b/website/versioned_docs/version-4.0/prefetch_method.mdx index 09bf159778..7c7d5c4e6b 100644 --- a/website/versioned_docs/version-4.0/prefetch_method.mdx +++ b/website/versioned_docs/version-4.0/prefetch_method.mdx @@ -48,11 +48,10 @@ Instead, GraphQLite offers an easier to implement solution: the ability to fetch class PostType { /** * @Field(prefetchMethod="prefetchUsers") - * @param Post $post * @param mixed $prefetchedUsers * @return User */ - public function getUser(Post $post, $prefetchedUsers): User + public function getUser($prefetchedUsers): User { // This method will receive the $prefetchedUsers as second argument. This is the return value of the "prefetchUsers" method below. // Using this prefetched list, it should be easy to map it to the post @@ -90,11 +89,10 @@ For instance: class PostType { /** * @Field(prefetchMethod="prefetchComments") - * @param Post $post * @param mixed $prefetchedComments * @return Comment[] */ - public function getComments(Post $post, $prefetchedComments): array + public function getComments($prefetchedComments): array { // ... } diff --git a/website/versioned_docs/version-4.1/prefetch_method.mdx b/website/versioned_docs/version-4.1/prefetch_method.mdx index f84a1f0fc0..ff987e637b 100644 --- a/website/versioned_docs/version-4.1/prefetch_method.mdx +++ b/website/versioned_docs/version-4.1/prefetch_method.mdx @@ -56,12 +56,11 @@ Instead, GraphQLite offers an easier to implement solution: the ability to fetch #[Type] class PostType { /** - * @param Post $post * @param mixed $prefetchedUsers * @return User */ #[Field(prefetchMethod: "prefetchUsers")] - public function getUser(Post $post, $prefetchedUsers): User + public function getUser($prefetchedUsers): User { // This method will receive the $prefetchedUsers as second argument. This is the return value of the "prefetchUsers" method below. // Using this prefetched list, it should be easy to map it to the post @@ -92,11 +91,10 @@ class PostType { class PostType { /** * @Field(prefetchMethod="prefetchUsers") - * @param Post $post * @param mixed $prefetchedUsers * @return User */ - public function getUser(Post $post, $prefetchedUsers): User + public function getUser($prefetchedUsers): User { // This method will receive the $prefetchedUsers as second argument. This is the return value of the "prefetchUsers" method below. // Using this prefetched list, it should be easy to map it to the post @@ -143,12 +141,11 @@ For instance: #[Type] class PostType { /** - * @param Post $post * @param mixed $prefetchedComments * @return Comment[] */ #[Field(prefetchMethod: "prefetchComments")] - public function getComments(Post $post, $prefetchedComments): array + public function getComments($prefetchedComments): array { // ... } @@ -175,11 +172,10 @@ class PostType { class PostType { /** * @Field(prefetchMethod="prefetchComments") - * @param Post $post * @param mixed $prefetchedComments * @return Comment[] */ - public function getComments(Post $post, $prefetchedComments): array + public function getComments($prefetchedComments): array { // ... } diff --git a/website/versioned_docs/version-4.2/prefetch-method.mdx b/website/versioned_docs/version-4.2/prefetch-method.mdx index 9df8877837..7efba9c07d 100644 --- a/website/versioned_docs/version-4.2/prefetch-method.mdx +++ b/website/versioned_docs/version-4.2/prefetch-method.mdx @@ -55,12 +55,11 @@ Instead, GraphQLite offers an easier to implement solution: the ability to fetch #[Type] class PostType { /** - * @param Post $post * @param mixed $prefetchedUsers * @return User */ #[Field(prefetchMethod: "prefetchUsers")] - public function getUser(Post $post, $prefetchedUsers): User + public function getUser($prefetchedUsers): User { // This method will receive the $prefetchedUsers as second argument. This is the return value of the "prefetchUsers" method below. // Using this prefetched list, it should be easy to map it to the post @@ -91,11 +90,10 @@ class PostType { class PostType { /** * @Field(prefetchMethod="prefetchUsers") - * @param Post $post * @param mixed $prefetchedUsers * @return User */ - public function getUser(Post $post, $prefetchedUsers): User + public function getUser($prefetchedUsers): User { // This method will receive the $prefetchedUsers as second argument. This is the return value of the "prefetchUsers" method below. // Using this prefetched list, it should be easy to map it to the post @@ -142,12 +140,11 @@ For instance: #[Type] class PostType { /** - * @param Post $post * @param mixed $prefetchedComments * @return Comment[] */ #[Field(prefetchMethod: "prefetchComments")] - public function getComments(Post $post, $prefetchedComments): array + public function getComments($prefetchedComments): array { // ... } @@ -174,11 +171,10 @@ class PostType { class PostType { /** * @Field(prefetchMethod="prefetchComments") - * @param Post $post * @param mixed $prefetchedComments * @return Comment[] */ - public function getComments(Post $post, $prefetchedComments): array + public function getComments($prefetchedComments): array { // ... } diff --git a/website/versioned_docs/version-4.3/prefetch-method.mdx b/website/versioned_docs/version-4.3/prefetch-method.mdx index 9df8877837..7efba9c07d 100644 --- a/website/versioned_docs/version-4.3/prefetch-method.mdx +++ b/website/versioned_docs/version-4.3/prefetch-method.mdx @@ -55,12 +55,11 @@ Instead, GraphQLite offers an easier to implement solution: the ability to fetch #[Type] class PostType { /** - * @param Post $post * @param mixed $prefetchedUsers * @return User */ #[Field(prefetchMethod: "prefetchUsers")] - public function getUser(Post $post, $prefetchedUsers): User + public function getUser($prefetchedUsers): User { // This method will receive the $prefetchedUsers as second argument. This is the return value of the "prefetchUsers" method below. // Using this prefetched list, it should be easy to map it to the post @@ -91,11 +90,10 @@ class PostType { class PostType { /** * @Field(prefetchMethod="prefetchUsers") - * @param Post $post * @param mixed $prefetchedUsers * @return User */ - public function getUser(Post $post, $prefetchedUsers): User + public function getUser($prefetchedUsers): User { // This method will receive the $prefetchedUsers as second argument. This is the return value of the "prefetchUsers" method below. // Using this prefetched list, it should be easy to map it to the post @@ -142,12 +140,11 @@ For instance: #[Type] class PostType { /** - * @param Post $post * @param mixed $prefetchedComments * @return Comment[] */ #[Field(prefetchMethod: "prefetchComments")] - public function getComments(Post $post, $prefetchedComments): array + public function getComments($prefetchedComments): array { // ... } @@ -174,11 +171,10 @@ class PostType { class PostType { /** * @Field(prefetchMethod="prefetchComments") - * @param Post $post * @param mixed $prefetchedComments * @return Comment[] */ - public function getComments(Post $post, $prefetchedComments): array + public function getComments($prefetchedComments): array { // ... } diff --git a/website/versioned_docs/version-5.0/prefetch-method.mdx b/website/versioned_docs/version-5.0/prefetch-method.mdx index 9df8877837..7efba9c07d 100644 --- a/website/versioned_docs/version-5.0/prefetch-method.mdx +++ b/website/versioned_docs/version-5.0/prefetch-method.mdx @@ -55,12 +55,11 @@ Instead, GraphQLite offers an easier to implement solution: the ability to fetch #[Type] class PostType { /** - * @param Post $post * @param mixed $prefetchedUsers * @return User */ #[Field(prefetchMethod: "prefetchUsers")] - public function getUser(Post $post, $prefetchedUsers): User + public function getUser($prefetchedUsers): User { // This method will receive the $prefetchedUsers as second argument. This is the return value of the "prefetchUsers" method below. // Using this prefetched list, it should be easy to map it to the post @@ -91,11 +90,10 @@ class PostType { class PostType { /** * @Field(prefetchMethod="prefetchUsers") - * @param Post $post * @param mixed $prefetchedUsers * @return User */ - public function getUser(Post $post, $prefetchedUsers): User + public function getUser($prefetchedUsers): User { // This method will receive the $prefetchedUsers as second argument. This is the return value of the "prefetchUsers" method below. // Using this prefetched list, it should be easy to map it to the post @@ -142,12 +140,11 @@ For instance: #[Type] class PostType { /** - * @param Post $post * @param mixed $prefetchedComments * @return Comment[] */ #[Field(prefetchMethod: "prefetchComments")] - public function getComments(Post $post, $prefetchedComments): array + public function getComments($prefetchedComments): array { // ... } @@ -174,11 +171,10 @@ class PostType { class PostType { /** * @Field(prefetchMethod="prefetchComments") - * @param Post $post * @param mixed $prefetchedComments * @return Comment[] */ - public function getComments(Post $post, $prefetchedComments): array + public function getComments($prefetchedComments): array { // ... } diff --git a/website/versioned_docs/version-6.0/prefetch-method.mdx b/website/versioned_docs/version-6.0/prefetch-method.mdx index 9df8877837..7efba9c07d 100644 --- a/website/versioned_docs/version-6.0/prefetch-method.mdx +++ b/website/versioned_docs/version-6.0/prefetch-method.mdx @@ -55,12 +55,11 @@ Instead, GraphQLite offers an easier to implement solution: the ability to fetch #[Type] class PostType { /** - * @param Post $post * @param mixed $prefetchedUsers * @return User */ #[Field(prefetchMethod: "prefetchUsers")] - public function getUser(Post $post, $prefetchedUsers): User + public function getUser($prefetchedUsers): User { // This method will receive the $prefetchedUsers as second argument. This is the return value of the "prefetchUsers" method below. // Using this prefetched list, it should be easy to map it to the post @@ -91,11 +90,10 @@ class PostType { class PostType { /** * @Field(prefetchMethod="prefetchUsers") - * @param Post $post * @param mixed $prefetchedUsers * @return User */ - public function getUser(Post $post, $prefetchedUsers): User + public function getUser($prefetchedUsers): User { // This method will receive the $prefetchedUsers as second argument. This is the return value of the "prefetchUsers" method below. // Using this prefetched list, it should be easy to map it to the post @@ -142,12 +140,11 @@ For instance: #[Type] class PostType { /** - * @param Post $post * @param mixed $prefetchedComments * @return Comment[] */ #[Field(prefetchMethod: "prefetchComments")] - public function getComments(Post $post, $prefetchedComments): array + public function getComments($prefetchedComments): array { // ... } @@ -174,11 +171,10 @@ class PostType { class PostType { /** * @Field(prefetchMethod="prefetchComments") - * @param Post $post * @param mixed $prefetchedComments * @return Comment[] */ - public function getComments(Post $post, $prefetchedComments): array + public function getComments($prefetchedComments): array { // ... } diff --git a/website/versioned_docs/version-6.1/prefetch-method.mdx b/website/versioned_docs/version-6.1/prefetch-method.mdx index 60519ab4d1..40aa5b873b 100644 --- a/website/versioned_docs/version-6.1/prefetch-method.mdx +++ b/website/versioned_docs/version-6.1/prefetch-method.mdx @@ -48,12 +48,11 @@ Instead, GraphQLite offers an easier to implement solution: the ability to fetch #[Type] class PostType { /** - * @param Post $post * @param mixed $prefetchedUsers * @return User */ #[Field(prefetchMethod: "prefetchUsers")] - public function getUser(Post $post, $prefetchedUsers): User + public function getUser($prefetchedUsers): User { // This method will receive the $prefetchedUsers as second argument. This is the return value of the "prefetchUsers" method below. // Using this prefetched list, it should be easy to map it to the post @@ -90,12 +89,11 @@ For instance: #[Type] class PostType { /** - * @param Post $post * @param mixed $prefetchedComments * @return Comment[] */ #[Field(prefetchMethod: "prefetchComments")] - public function getComments(Post $post, $prefetchedComments): array + public function getComments($prefetchedComments): array { // ... } From 96c323a2dd270bcde2a14836571cc7a2bfbb2e3b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Sep 2023 21:01:33 -0400 Subject: [PATCH 028/108] Bump actions/checkout from 3 to 4 (#617) Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/continuous_integration.yml | 2 +- .github/workflows/doc_generation.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index fc623f91bc..cceb787074 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -29,7 +29,7 @@ jobs: access_token: ${{ github.token }} - name: "Checkout" - uses: "actions/checkout@v3" + uses: "actions/checkout@v4" - name: "Install PHP with extensions" uses: "shivammathur/setup-php@v2" diff --git a/.github/workflows/doc_generation.yml b/.github/workflows/doc_generation.yml index 12834aa8d1..019a1ced11 100644 --- a/.github/workflows/doc_generation.yml +++ b/.github/workflows/doc_generation.yml @@ -19,7 +19,7 @@ jobs: steps: - name: "Checkout" - uses: "actions/checkout@v3" + uses: "actions/checkout@v4" - name: "Setup NodeJS" uses: actions/setup-node@v3 From d286d558e673dde04ef538fcf5ab607bad8bd70c Mon Sep 17 00:00:00 2001 From: Oleksandr Prypkhan Date: Tue, 12 Sep 2023 08:08:37 +0300 Subject: [PATCH 029/108] Automatic query complexity (#612) * Automatic query complexity * Fix tests after master merge * Code style * Add annotation reference and add some tests * Fix PHPStan fails * Fix PHPUnit test on prefer-lowest * Fix docs generation --- src/Annotations/Cost.php | 23 + src/Annotations/MiddlewareAnnotations.php | 12 +- src/Http/Psr15GraphQLMiddlewareBuilder.php | 34 ++ .../AuthorizationFieldMiddleware.php | 4 - .../AuthorizationInputFieldMiddleware.php | 3 - src/Middlewares/CostFieldMiddleware.php | 73 ++++ src/Middlewares/SecurityFieldMiddleware.php | 1 - src/QueryFieldDescriptor.php | 9 + src/SchemaFactory.php | 12 +- .../Controllers/ArticleController.php | 16 + tests/Fixtures/Integration/Models/Post.php | 3 + tests/Integration/EndToEndTest.php | 306 +------------- tests/Integration/IntegrationTestCase.php | 393 ++++++++++++++++++ tests/Integration/QueryComplexityTest.php | 201 +++++++++ .../PrefetchParameterMiddlewareTest.php | 2 + tests/Middlewares/CostFieldMiddlewareTest.php | 163 ++++++++ tests/ParameterizedCallableResolverTest.php | 3 +- tests/QueryFieldDescriptorTest.php | 21 + website/docs/annotations-reference.md | 10 + website/docs/operation-complexity.md | 223 ++++++++++ website/sidebars.json | 3 +- 21 files changed, 1194 insertions(+), 321 deletions(-) create mode 100644 src/Annotations/Cost.php create mode 100644 src/Middlewares/CostFieldMiddleware.php create mode 100644 tests/Integration/IntegrationTestCase.php create mode 100644 tests/Integration/QueryComplexityTest.php create mode 100644 tests/Middlewares/CostFieldMiddlewareTest.php create mode 100644 website/docs/operation-complexity.md diff --git a/src/Annotations/Cost.php b/src/Annotations/Cost.php new file mode 100644 index 0000000000..8e09f4de18 --- /dev/null +++ b/src/Annotations/Cost.php @@ -0,0 +1,23 @@ + + * @param class-string $className + * + * @return array + * + * @template TAnnotation of MiddlewareAnnotationInterface */ public function getAnnotationsByType(string $className): array { @@ -32,6 +36,12 @@ public function getAnnotationsByType(string $className): array /** * Returns at most 1 annotation of the $className type. + * + * @param class-string $className + * + * @return TAnnotation|null + * + * @template TAnnotation of MiddlewareAnnotationInterface */ public function getAnnotationByType(string $className): MiddlewareAnnotationInterface|null { diff --git a/src/Http/Psr15GraphQLMiddlewareBuilder.php b/src/Http/Psr15GraphQLMiddlewareBuilder.php index 0ee4565771..76bb4d6de8 100644 --- a/src/Http/Psr15GraphQLMiddlewareBuilder.php +++ b/src/Http/Psr15GraphQLMiddlewareBuilder.php @@ -8,6 +8,9 @@ use GraphQL\Error\DebugFlag; use GraphQL\Server\ServerConfig; use GraphQL\Type\Schema; +use GraphQL\Validator\DocumentValidator; +use GraphQL\Validator\Rules\QueryComplexity; +use GraphQL\Validator\Rules\ValidationRule; use Laminas\Diactoros\ResponseFactory; use Laminas\Diactoros\StreamFactory; use Psr\Http\Message\ResponseFactoryInterface; @@ -21,6 +24,7 @@ use TheCodingMachine\GraphQLite\Server\PersistedQuery\NotSupportedPersistedQueryLoader; use function class_exists; +use function is_callable; /** * A factory generating a PSR-15 middleware tailored for GraphQLite. @@ -38,6 +42,9 @@ class Psr15GraphQLMiddlewareBuilder private HttpCodeDeciderInterface $httpCodeDecider; + /** @var ValidationRule[] */ + private array $addedValidationRules = []; + public function __construct(Schema $schema) { $this->config = new ServerConfig(); @@ -97,6 +104,18 @@ public function useAutomaticPersistedQueries(CacheInterface $cache, DateInterval return $this; } + public function limitQueryComplexity(int $complexity): self + { + return $this->addValidationRule(new QueryComplexity($complexity)); + } + + public function addValidationRule(ValidationRule $rule): self + { + $this->addedValidationRules[] = $rule; + + return $this; + } + public function createMiddleware(): MiddlewareInterface { if ($this->responseFactory === null && ! class_exists(ResponseFactory::class)) { @@ -109,6 +128,21 @@ public function createMiddleware(): MiddlewareInterface } $this->streamFactory = $this->streamFactory ?: new StreamFactory(); + // If getValidationRules() is null in the config, DocumentValidator will default to DocumentValidator::allRules(). + // So if we only added given rule, all of the default rules would not be validated, so we must also provide them. + $originalValidationRules = $this->config->getValidationRules() ?? DocumentValidator::allRules(); + + $this->config->setValidationRules(function (...$args) use ($originalValidationRules) { + if (is_callable($originalValidationRules)) { + $originalValidationRules = $originalValidationRules(...$args); + } + + return [ + ...$originalValidationRules, + ...$this->addedValidationRules, + ]; + }); + return new WebonyxGraphqlMiddleware($this->config, $this->responseFactory, $this->streamFactory, $this->httpCodeDecider, $this->url); } } diff --git a/src/Middlewares/AuthorizationFieldMiddleware.php b/src/Middlewares/AuthorizationFieldMiddleware.php index e1e8acde06..6e2a7cdc1e 100644 --- a/src/Middlewares/AuthorizationFieldMiddleware.php +++ b/src/Middlewares/AuthorizationFieldMiddleware.php @@ -34,9 +34,7 @@ public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandler $annotations = $queryFieldDescriptor->getMiddlewareAnnotations(); $loggedAnnotation = $annotations->getAnnotationByType(Logged::class); - assert($loggedAnnotation === null || $loggedAnnotation instanceof Logged); $rightAnnotation = $annotations->getAnnotationByType(Right::class); - assert($rightAnnotation === null || $rightAnnotation instanceof Right); // Avoid wrapping resolver callback when no annotations are specified. if (! $loggedAnnotation && ! $rightAnnotation) { @@ -44,9 +42,7 @@ public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandler } $failWith = $annotations->getAnnotationByType(FailWith::class); - assert($failWith === null || $failWith instanceof FailWith); $hideIfUnauthorized = $annotations->getAnnotationByType(HideIfUnauthorized::class); - assert($hideIfUnauthorized instanceof HideIfUnauthorized || $hideIfUnauthorized === null); if ($failWith !== null && $hideIfUnauthorized !== null) { throw IncompatibleAnnotationsException::cannotUseFailWithAndHide(); diff --git a/src/Middlewares/AuthorizationInputFieldMiddleware.php b/src/Middlewares/AuthorizationInputFieldMiddleware.php index 3e78b87960..670bd973d6 100644 --- a/src/Middlewares/AuthorizationInputFieldMiddleware.php +++ b/src/Middlewares/AuthorizationInputFieldMiddleware.php @@ -32,9 +32,7 @@ public function process(InputFieldDescriptor $inputFieldDescriptor, InputFieldHa $annotations = $inputFieldDescriptor->getMiddlewareAnnotations(); $loggedAnnotation = $annotations->getAnnotationByType(Logged::class); - assert($loggedAnnotation === null || $loggedAnnotation instanceof Logged); $rightAnnotation = $annotations->getAnnotationByType(Right::class); - assert($rightAnnotation === null || $rightAnnotation instanceof Right); // Avoid wrapping resolver callback when no annotations are specified. if (! $loggedAnnotation && ! $rightAnnotation) { @@ -42,7 +40,6 @@ public function process(InputFieldDescriptor $inputFieldDescriptor, InputFieldHa } $hideIfUnauthorized = $annotations->getAnnotationByType(HideIfUnauthorized::class); - assert($hideIfUnauthorized instanceof HideIfUnauthorized || $hideIfUnauthorized === null); if ($hideIfUnauthorized !== null && ! $this->isAuthorized($loggedAnnotation, $rightAnnotation)) { return null; diff --git a/src/Middlewares/CostFieldMiddleware.php b/src/Middlewares/CostFieldMiddleware.php new file mode 100644 index 0000000000..56daa62328 --- /dev/null +++ b/src/Middlewares/CostFieldMiddleware.php @@ -0,0 +1,73 @@ +getMiddlewareAnnotations()->getAnnotationByType(Cost::class); + + if (! $costAttribute) { + return $fieldHandler->handle($queryFieldDescriptor); + } + + $field = $fieldHandler->handle( + $queryFieldDescriptor->withAddedCommentLines($this->buildQueryComment($costAttribute)), + ); + + if (! $field) { + return $field; + } + + $field->complexityFn = static function (int $childrenComplexity, array $fieldArguments) use ($costAttribute): int { + if (! $costAttribute->multipliers) { + return $costAttribute->complexity + $childrenComplexity; + } + + $cost = $costAttribute->complexity + $childrenComplexity; + $needsDefaultMultiplier = true; + + foreach ($costAttribute->multipliers as $multiplier) { + $value = $fieldArguments[$multiplier] ?? null; + + if (! is_int($value)) { + continue; + } + + $cost *= $value; + $needsDefaultMultiplier = false; + } + + if ($needsDefaultMultiplier && $costAttribute->defaultMultiplier !== null) { + $cost *= $costAttribute->defaultMultiplier; + } + + return $cost; + }; + + return $field; + } + + private function buildQueryComment(Cost $costAttribute): string + { + return 'Cost: ' . + implode(', ', [ + 'complexity = ' . $costAttribute->complexity, + 'multipliers = [' . implode(', ', $costAttribute->multipliers) . ']', + 'defaultMultiplier = ' . ($costAttribute->defaultMultiplier ?? 'null'), + ]); + } +} diff --git a/src/Middlewares/SecurityFieldMiddleware.php b/src/Middlewares/SecurityFieldMiddleware.php index d0884bd418..c3182dd962 100644 --- a/src/Middlewares/SecurityFieldMiddleware.php +++ b/src/Middlewares/SecurityFieldMiddleware.php @@ -42,7 +42,6 @@ public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandler } $failWith = $annotations->getAnnotationByType(FailWith::class); - assert($failWith instanceof FailWith || $failWith === null); // If the failWith value is null and the return type is non nullable, we must set it to nullable. $makeReturnTypeNullable = false; diff --git a/src/QueryFieldDescriptor.php b/src/QueryFieldDescriptor.php index 5efa1de983..e12b88a2ee 100644 --- a/src/QueryFieldDescriptor.php +++ b/src/QueryFieldDescriptor.php @@ -174,6 +174,15 @@ public function withComment(string|null $comment): self return $this->with(comment: $comment); } + public function withAddedCommentLines(string $comment): self + { + if (! $this->comment) { + return $this->withComment($comment); + } + + return $this->withComment($this->comment . "\n" . $comment); + } + public function getDeprecationReason(): string|null { return $this->deprecationReason; diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index b612ee745e..11dcdf71f7 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -41,6 +41,7 @@ use TheCodingMachine\GraphQLite\Mappers\TypeMapperInterface; use TheCodingMachine\GraphQLite\Middlewares\AuthorizationFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\AuthorizationInputFieldMiddleware; +use TheCodingMachine\GraphQLite\Middlewares\CostFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewareInterface; use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewarePipe; use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewareInterface; @@ -211,9 +212,7 @@ public function addParameterMiddleware(ParameterMiddlewareInterface $parameterMi return $this; } - /** - * @deprecated Use PHP8 Attributes instead - */ + /** @deprecated Use PHP8 Attributes instead */ public function setDoctrineAnnotationReader(Reader $annotationReader): self { $this->doctrineAnnotationReader = $annotationReader; @@ -349,7 +348,7 @@ public function createSchema(): Schema $namespaceFactory = new NamespaceFactory($namespacedCache, $this->classNameMapper, $this->globTTL); $nsList = array_map( - static fn(string $namespace) => $namespaceFactory->createNamespace($namespace), + static fn (string $namespace) => $namespaceFactory->createNamespace($namespace), $this->typeNamespaces, ); @@ -363,6 +362,7 @@ public function createSchema(): Schema // TODO: add a logger to the SchemaFactory and make use of it everywhere (and most particularly in SecurityFieldMiddleware) $fieldMiddlewarePipe->pipe(new SecurityFieldMiddleware($expressionLanguage, $authenticationService, $authorizationService)); $fieldMiddlewarePipe->pipe(new AuthorizationFieldMiddleware($authenticationService, $authorizationService)); + $fieldMiddlewarePipe->pipe(new CostFieldMiddleware()); $inputFieldMiddlewarePipe = new InputFieldMiddlewarePipe(); foreach ($this->inputFieldMiddlewares as $inputFieldMiddleware) { @@ -390,7 +390,7 @@ public function createSchema(): Schema $rootTypeMapper = new MyCLabsEnumTypeMapper($rootTypeMapper, $annotationReader, $symfonyCache, $nsList); } - if (!empty($this->rootTypeMapperFactories)) { + if (! empty($this->rootTypeMapperFactories)) { $rootSchemaFactoryContext = new RootTypeMapperFactoryContext( $annotationReader, $typeResolver, @@ -458,7 +458,7 @@ public function createSchema(): Schema )); } - if (!empty($this->typeMapperFactories) || !empty($this->queryProviderFactories)) { + if (! empty($this->typeMapperFactories) || ! empty($this->queryProviderFactories)) { $context = new FactoryContext( $annotationReader, $typeResolver, diff --git a/tests/Fixtures/Integration/Controllers/ArticleController.php b/tests/Fixtures/Integration/Controllers/ArticleController.php index f682ca585b..ca2c32eff4 100644 --- a/tests/Fixtures/Integration/Controllers/ArticleController.php +++ b/tests/Fixtures/Integration/Controllers/ArticleController.php @@ -2,11 +2,27 @@ namespace TheCodingMachine\GraphQLite\Fixtures\Integration\Controllers; +use TheCodingMachine\GraphQLite\Annotations\Cost; use TheCodingMachine\GraphQLite\Annotations\Mutation; +use TheCodingMachine\GraphQLite\Annotations\Query; use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Article; +use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Contact; +use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\User; class ArticleController { + /** + * @return Article[] + */ + #[Query] + #[Cost(complexity: 5, multipliers: ['take'], defaultMultiplier: 500)] + public function articles(?int $take = 10): array + { + return [ + new Article('Title'), + ]; + } + /** * @Mutation() diff --git a/tests/Fixtures/Integration/Models/Post.php b/tests/Fixtures/Integration/Models/Post.php index 8b611202d2..ae91b13ae0 100644 --- a/tests/Fixtures/Integration/Models/Post.php +++ b/tests/Fixtures/Integration/Models/Post.php @@ -3,6 +3,7 @@ namespace TheCodingMachine\GraphQLite\Fixtures\Integration\Models; use DateTimeInterface; +use TheCodingMachine\GraphQLite\Annotations\Cost; use TheCodingMachine\GraphQLite\Annotations\Field; use TheCodingMachine\GraphQLite\Annotations\Input; use TheCodingMachine\GraphQLite\Annotations\Type; @@ -38,6 +39,7 @@ class Post * @Field(name="comment") * @var string|null */ + #[Cost(complexity: 5)] private $description = 'foo'; /** @@ -50,6 +52,7 @@ class Post * @Field() * @var Contact|null */ + #[Cost(complexity: 3)] public $author = null; /** diff --git a/tests/Integration/EndToEndTest.php b/tests/Integration/EndToEndTest.php index 93d6f7f41f..2e2e4ed847 100644 --- a/tests/Integration/EndToEndTest.php +++ b/tests/Integration/EndToEndTest.php @@ -57,6 +57,7 @@ use TheCodingMachine\GraphQLite\Mappers\TypeMapperInterface; use TheCodingMachine\GraphQLite\Middlewares\AuthorizationFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\AuthorizationInputFieldMiddleware; +use TheCodingMachine\GraphQLite\Middlewares\CostFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewareInterface; use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewarePipe; use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewareInterface; @@ -95,311 +96,8 @@ use const JSON_PRETTY_PRINT; -class EndToEndTest extends TestCase +class EndToEndTest extends IntegrationTestCase { - private ContainerInterface $mainContainer; - - public function setUp(): void - { - $this->mainContainer = $this->createContainer(); - } - - /** @param array $overloadedServices */ - public function createContainer(array $overloadedServices = []): ContainerInterface - { - $services = [ - Schema::class => static function (ContainerInterface $container) { - return new Schema($container->get(QueryProviderInterface::class), $container->get(RecursiveTypeMapperInterface::class), $container->get(TypeResolver::class), $container->get(RootTypeMapperInterface::class)); - }, - QueryProviderInterface::class => static function (ContainerInterface $container) { - $queryProvider = new GlobControllerQueryProvider( - 'TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers', - $container->get(FieldsBuilder::class), - $container->get(BasicAutoWiringContainer::class), - $container->get(AnnotationReader::class), - new Psr16Cache(new ArrayAdapter()), - ); - - if (interface_exists(UnitEnum::class)) { - $queryProvider = new AggregateQueryProvider([ - $queryProvider, - new GlobControllerQueryProvider( - 'TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Controllers', - $container->get(FieldsBuilder::class), - $container->get(BasicAutoWiringContainer::class), - $container->get(AnnotationReader::class), - new Psr16Cache(new ArrayAdapter()), - ), - ]); - } - return $queryProvider; - }, - FieldsBuilder::class => static function (ContainerInterface $container) { - $parameterMiddlewarePipe = $container->get(ParameterMiddlewareInterface::class); - $fieldsBuilder = new FieldsBuilder( - $container->get(AnnotationReader::class), - $container->get(RecursiveTypeMapperInterface::class), - $container->get(ArgumentResolver::class), - $container->get(TypeResolver::class), - $container->get(CachedDocBlockFactory::class), - $container->get(NamingStrategyInterface::class), - $container->get(RootTypeMapperInterface::class), - $parameterMiddlewarePipe, - $container->get(FieldMiddlewareInterface::class), - $container->get(InputFieldMiddlewareInterface::class), - ); - $parameterizedCallableResolver = new ParameterizedCallableResolver($fieldsBuilder, $container); - - $parameterMiddlewarePipe->pipe(new PrefetchParameterMiddleware($parameterizedCallableResolver)); - - return $fieldsBuilder; - }, - FieldMiddlewareInterface::class => static function (ContainerInterface $container) { - $pipe = new FieldMiddlewarePipe(); - $pipe->pipe($container->get(AuthorizationFieldMiddleware::class)); - $pipe->pipe($container->get(SecurityFieldMiddleware::class)); - return $pipe; - }, - InputFieldMiddlewareInterface::class => static function (ContainerInterface $container) { - $pipe = new InputFieldMiddlewarePipe(); - $pipe->pipe($container->get(AuthorizationInputFieldMiddleware::class)); - $pipe->pipe($container->get(SecurityInputFieldMiddleware::class)); - return $pipe; - }, - AuthorizationInputFieldMiddleware::class => static function (ContainerInterface $container) { - return new AuthorizationInputFieldMiddleware( - $container->get(AuthenticationServiceInterface::class), - $container->get(AuthorizationServiceInterface::class), - ); - }, - SecurityInputFieldMiddleware::class => static function (ContainerInterface $container) { - return new SecurityInputFieldMiddleware( - new ExpressionLanguage(new Psr16Adapter(new Psr16Cache(new ArrayAdapter())), [new SecurityExpressionLanguageProvider()]), - $container->get(AuthenticationServiceInterface::class), - $container->get(AuthorizationServiceInterface::class), - ); - }, - AuthorizationFieldMiddleware::class => static function (ContainerInterface $container) { - return new AuthorizationFieldMiddleware( - $container->get(AuthenticationServiceInterface::class), - $container->get(AuthorizationServiceInterface::class), - ); - }, - SecurityFieldMiddleware::class => static function (ContainerInterface $container) { - return new SecurityFieldMiddleware( - new ExpressionLanguage(new Psr16Adapter(new Psr16Cache(new ArrayAdapter())), [new SecurityExpressionLanguageProvider()]), - $container->get(AuthenticationServiceInterface::class), - $container->get(AuthorizationServiceInterface::class), - ); - }, - ArgumentResolver::class => static function (ContainerInterface $container) { - return new ArgumentResolver(); - }, - TypeResolver::class => static function (ContainerInterface $container) { - return new TypeResolver(); - }, - BasicAutoWiringContainer::class => static function (ContainerInterface $container) { - return new BasicAutoWiringContainer(new EmptyContainer()); - }, - AuthorizationServiceInterface::class => static function (ContainerInterface $container) { - return new VoidAuthorizationService(); - }, - AuthenticationServiceInterface::class => static function (ContainerInterface $container) { - return new VoidAuthenticationService(); - }, - RecursiveTypeMapperInterface::class => static function (ContainerInterface $container) { - $arrayAdapter = new ArrayAdapter(); - $arrayAdapter->setLogger(new ExceptionLogger()); - return new RecursiveTypeMapper( - $container->get(TypeMapperInterface::class), - $container->get(NamingStrategyInterface::class), - new Psr16Cache($arrayAdapter), - $container->get(TypeRegistry::class), - $container->get(AnnotationReader::class), - ); - }, - TypeMapperInterface::class => static function (ContainerInterface $container) { - return new CompositeTypeMapper(); - }, - NamespaceFactory::class => static function (ContainerInterface $container) { - $arrayAdapter = new ArrayAdapter(); - $arrayAdapter->setLogger(new ExceptionLogger()); - return new NamespaceFactory(new Psr16Cache($arrayAdapter)); - }, - GlobTypeMapper::class => static function (ContainerInterface $container) { - $arrayAdapter = new ArrayAdapter(); - $arrayAdapter->setLogger(new ExceptionLogger()); - return new GlobTypeMapper( - $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Types'), - $container->get(TypeGenerator::class), - $container->get(InputTypeGenerator::class), - $container->get(InputTypeUtils::class), - $container->get(BasicAutoWiringContainer::class), - $container->get(AnnotationReader::class), - $container->get(NamingStrategyInterface::class), - $container->get(RecursiveTypeMapperInterface::class), - new Psr16Cache($arrayAdapter), - ); - }, - // We use a second type mapper here so we can target the Models dir - GlobTypeMapper::class . '2' => static function (ContainerInterface $container) { - $arrayAdapter = new ArrayAdapter(); - $arrayAdapter->setLogger(new ExceptionLogger()); - return new GlobTypeMapper( - $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models'), - $container->get(TypeGenerator::class), - $container->get(InputTypeGenerator::class), - $container->get(InputTypeUtils::class), - $container->get(BasicAutoWiringContainer::class), - $container->get(AnnotationReader::class), - $container->get(NamingStrategyInterface::class), - $container->get(RecursiveTypeMapperInterface::class), - new Psr16Cache($arrayAdapter), - ); - }, - PorpaginasTypeMapper::class => static function (ContainerInterface $container) { - return new PorpaginasTypeMapper($container->get(RecursiveTypeMapperInterface::class)); - }, - EnumTypeMapper::class => static function (ContainerInterface $container) { - return new EnumTypeMapper( - $container->get(RootTypeMapperInterface::class), - $container->get(AnnotationReader::class), - new ArrayAdapter(), - [ - $container->get(NamespaceFactory::class) - ->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Models'), - ], - ); - }, - TypeGenerator::class => static function (ContainerInterface $container) { - return new TypeGenerator( - $container->get(AnnotationReader::class), - $container->get(NamingStrategyInterface::class), - $container->get(TypeRegistry::class), - $container->get(BasicAutoWiringContainer::class), - $container->get(RecursiveTypeMapperInterface::class), - $container->get(FieldsBuilder::class), - ); - }, - TypeRegistry::class => static function () { - return new TypeRegistry(); - }, - InputTypeGenerator::class => static function (ContainerInterface $container) { - return new InputTypeGenerator( - $container->get(InputTypeUtils::class), - $container->get(FieldsBuilder::class), - ); - }, - InputTypeUtils::class => static function (ContainerInterface $container) { - return new InputTypeUtils( - $container->get(AnnotationReader::class), - $container->get(NamingStrategyInterface::class), - ); - }, - AnnotationReader::class => static function (ContainerInterface $container) { - return new AnnotationReader(new DoctrineAnnotationReader()); - }, - NamingStrategyInterface::class => static function () { - return new NamingStrategy(); - }, - CachedDocBlockFactory::class => static function () { - $arrayAdapter = new ArrayAdapter(); - $arrayAdapter->setLogger(new ExceptionLogger()); - return new CachedDocBlockFactory(new Psr16Cache($arrayAdapter)); - }, - RootTypeMapperInterface::class => static function (ContainerInterface $container) { - return new VoidTypeMapper( - new NullableTypeMapperAdapter( - $container->get('topRootTypeMapper') - ) - ); - }, - 'topRootTypeMapper' => static function () { - return new LastDelegatingTypeMapper(); - }, - 'rootTypeMapper' => static function (ContainerInterface $container) { - // These are in reverse order of execution - $errorRootTypeMapper = new FinalRootTypeMapper($container->get(RecursiveTypeMapperInterface::class)); - $rootTypeMapper = new BaseTypeMapper($errorRootTypeMapper, $container->get(RecursiveTypeMapperInterface::class), $container->get(RootTypeMapperInterface::class)); - $rootTypeMapper = new MyCLabsEnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), new ArrayAdapter(), [$container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models')]); - if (interface_exists(UnitEnum::class)) { - $rootTypeMapper = new EnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), new ArrayAdapter(), [$container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Models')]); - } - $rootTypeMapper = new CompoundTypeMapper($rootTypeMapper, $container->get(RootTypeMapperInterface::class), $container->get(NamingStrategyInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class)); - $rootTypeMapper = new IteratorTypeMapper($rootTypeMapper, $container->get(RootTypeMapperInterface::class)); - return $rootTypeMapper; - }, - ContainerParameterHandler::class => static function (ContainerInterface $container) { - return new ContainerParameterHandler($container, true, true); - }, - InjectUserParameterHandler::class => static function (ContainerInterface $container) { - return new InjectUserParameterHandler($container->get(AuthenticationServiceInterface::class)); - }, - 'testService' => static function () { - return 'foo'; - }, - stdClass::class => static function () { - // Empty test service for autowiring - return new stdClass(); - }, - ParameterMiddlewareInterface::class => static function (ContainerInterface $container) { - $parameterMiddlewarePipe = new ParameterMiddlewarePipe(); - $parameterMiddlewarePipe->pipe(new ResolveInfoParameterHandler()); - $parameterMiddlewarePipe->pipe($container->get(ContainerParameterHandler::class)); - $parameterMiddlewarePipe->pipe($container->get(InjectUserParameterHandler::class)); - - return $parameterMiddlewarePipe; - }, - ]; - - if (interface_exists(UnitEnum::class)) { - // Register another instance of GlobTypeMapper to process our PHP 8.1 enums and/or other - // 8.1 supported features. - $services[GlobTypeMapper::class . '3'] = static function (ContainerInterface $container) { - $arrayAdapter = new ArrayAdapter(); - $arrayAdapter->setLogger(new ExceptionLogger()); - return new GlobTypeMapper( - $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Models'), - $container->get(TypeGenerator::class), - $container->get(InputTypeGenerator::class), - $container->get(InputTypeUtils::class), - $container->get(BasicAutoWiringContainer::class), - $container->get(AnnotationReader::class), - $container->get(NamingStrategyInterface::class), - $container->get(RecursiveTypeMapperInterface::class), - new Psr16Cache($arrayAdapter), - ); - }; - } - - $container = new LazyContainer($overloadedServices + $services); - $container->get(TypeResolver::class)->registerSchema($container->get(Schema::class)); - $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(GlobTypeMapper::class)); - $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(GlobTypeMapper::class . '2')); - if (interface_exists(UnitEnum::class)) { - $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(GlobTypeMapper::class . '3')); - } - $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(PorpaginasTypeMapper::class)); - - $container->get('topRootTypeMapper')->setNext($container->get('rootTypeMapper')); - /*$container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new CompoundTypeMapper($container->get(RootTypeMapperInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class))); - $container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new IteratorTypeMapper($container->get(RootTypeMapperInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class))); - $container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new IteratorTypeMapper($container->get(RootTypeMapperInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class))); - $container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new MyCLabsEnumTypeMapper()); - $container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new BaseTypeMapper($container->get(RecursiveTypeMapperInterface::class), $container->get(RootTypeMapperInterface::class))); -*/ - return $container; - } - - private function getSuccessResult(ExecutionResult $result, int $debugFlag = DebugFlag::RETHROW_INTERNAL_EXCEPTIONS): mixed - { - $array = $result->toArray($debugFlag); - if (isset($array['errors']) || !isset($array['data'])) { - $this->fail('Expected a successful answer. Got ' . json_encode($array, JSON_PRETTY_PRINT)); - } - return $array['data']; - } - public function testEndToEnd(): void { $schema = $this->mainContainer->get(Schema::class); diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php new file mode 100644 index 0000000000..e9d843347a --- /dev/null +++ b/tests/Integration/IntegrationTestCase.php @@ -0,0 +1,393 @@ +mainContainer = $this->createContainer(); + } + + /** @param array $overloadedServices */ + public function createContainer(array $overloadedServices = []): ContainerInterface + { + $services = [ + Schema::class => static function (ContainerInterface $container) { + return new Schema($container->get(QueryProviderInterface::class), $container->get(RecursiveTypeMapperInterface::class), $container->get(TypeResolver::class), $container->get(RootTypeMapperInterface::class)); + }, + QueryProviderInterface::class => static function (ContainerInterface $container) { + $queryProvider = new GlobControllerQueryProvider( + 'TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers', + $container->get(FieldsBuilder::class), + $container->get(BasicAutoWiringContainer::class), + $container->get(AnnotationReader::class), + new Psr16Cache(new ArrayAdapter()), + ); + + if (interface_exists(UnitEnum::class)) { + $queryProvider = new AggregateQueryProvider([ + $queryProvider, + new GlobControllerQueryProvider( + 'TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Controllers', + $container->get(FieldsBuilder::class), + $container->get(BasicAutoWiringContainer::class), + $container->get(AnnotationReader::class), + new Psr16Cache(new ArrayAdapter()), + ), + ]); + } + return $queryProvider; + }, + FieldsBuilder::class => static function (ContainerInterface $container) { + $parameterMiddlewarePipe = $container->get(ParameterMiddlewareInterface::class); + $fieldsBuilder = new FieldsBuilder( + $container->get(AnnotationReader::class), + $container->get(RecursiveTypeMapperInterface::class), + $container->get(ArgumentResolver::class), + $container->get(TypeResolver::class), + $container->get(CachedDocBlockFactory::class), + $container->get(NamingStrategyInterface::class), + $container->get(RootTypeMapperInterface::class), + $parameterMiddlewarePipe, + $container->get(FieldMiddlewareInterface::class), + $container->get(InputFieldMiddlewareInterface::class), + ); + $parameterizedCallableResolver = new ParameterizedCallableResolver($fieldsBuilder, $container); + + $parameterMiddlewarePipe->pipe(new PrefetchParameterMiddleware($parameterizedCallableResolver)); + + return $fieldsBuilder; + }, + FieldMiddlewareInterface::class => static function (ContainerInterface $container) { + $pipe = new FieldMiddlewarePipe(); + $pipe->pipe($container->get(AuthorizationFieldMiddleware::class)); + $pipe->pipe($container->get(SecurityFieldMiddleware::class)); + $pipe->pipe($container->get(CostFieldMiddleware::class)); + return $pipe; + }, + InputFieldMiddlewareInterface::class => static function (ContainerInterface $container) { + $pipe = new InputFieldMiddlewarePipe(); + $pipe->pipe($container->get(AuthorizationInputFieldMiddleware::class)); + $pipe->pipe($container->get(SecurityInputFieldMiddleware::class)); + return $pipe; + }, + AuthorizationInputFieldMiddleware::class => static function (ContainerInterface $container) { + return new AuthorizationInputFieldMiddleware( + $container->get(AuthenticationServiceInterface::class), + $container->get(AuthorizationServiceInterface::class), + ); + }, + SecurityInputFieldMiddleware::class => static function (ContainerInterface $container) { + return new SecurityInputFieldMiddleware( + new ExpressionLanguage(new Psr16Adapter(new Psr16Cache(new ArrayAdapter())), [new SecurityExpressionLanguageProvider()]), + $container->get(AuthenticationServiceInterface::class), + $container->get(AuthorizationServiceInterface::class), + ); + }, + AuthorizationFieldMiddleware::class => static function (ContainerInterface $container) { + return new AuthorizationFieldMiddleware( + $container->get(AuthenticationServiceInterface::class), + $container->get(AuthorizationServiceInterface::class), + ); + }, + SecurityFieldMiddleware::class => static function (ContainerInterface $container) { + return new SecurityFieldMiddleware( + new ExpressionLanguage(new Psr16Adapter(new Psr16Cache(new ArrayAdapter())), [new SecurityExpressionLanguageProvider()]), + $container->get(AuthenticationServiceInterface::class), + $container->get(AuthorizationServiceInterface::class), + ); + }, + CostFieldMiddleware::class => fn () => new CostFieldMiddleware(), + ArgumentResolver::class => static function (ContainerInterface $container) { + return new ArgumentResolver(); + }, + TypeResolver::class => static function (ContainerInterface $container) { + return new TypeResolver(); + }, + BasicAutoWiringContainer::class => static function (ContainerInterface $container) { + return new BasicAutoWiringContainer(new EmptyContainer()); + }, + AuthorizationServiceInterface::class => static function (ContainerInterface $container) { + return new VoidAuthorizationService(); + }, + AuthenticationServiceInterface::class => static function (ContainerInterface $container) { + return new VoidAuthenticationService(); + }, + RecursiveTypeMapperInterface::class => static function (ContainerInterface $container) { + $arrayAdapter = new ArrayAdapter(); + $arrayAdapter->setLogger(new ExceptionLogger()); + return new RecursiveTypeMapper( + $container->get(TypeMapperInterface::class), + $container->get(NamingStrategyInterface::class), + new Psr16Cache($arrayAdapter), + $container->get(TypeRegistry::class), + $container->get(AnnotationReader::class), + ); + }, + TypeMapperInterface::class => static function (ContainerInterface $container) { + return new CompositeTypeMapper(); + }, + NamespaceFactory::class => static function (ContainerInterface $container) { + $arrayAdapter = new ArrayAdapter(); + $arrayAdapter->setLogger(new ExceptionLogger()); + return new NamespaceFactory(new Psr16Cache($arrayAdapter)); + }, + GlobTypeMapper::class => static function (ContainerInterface $container) { + $arrayAdapter = new ArrayAdapter(); + $arrayAdapter->setLogger(new ExceptionLogger()); + return new GlobTypeMapper( + $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Types'), + $container->get(TypeGenerator::class), + $container->get(InputTypeGenerator::class), + $container->get(InputTypeUtils::class), + $container->get(BasicAutoWiringContainer::class), + $container->get(AnnotationReader::class), + $container->get(NamingStrategyInterface::class), + $container->get(RecursiveTypeMapperInterface::class), + new Psr16Cache($arrayAdapter), + ); + }, + // We use a second type mapper here so we can target the Models dir + GlobTypeMapper::class . '2' => static function (ContainerInterface $container) { + $arrayAdapter = new ArrayAdapter(); + $arrayAdapter->setLogger(new ExceptionLogger()); + return new GlobTypeMapper( + $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models'), + $container->get(TypeGenerator::class), + $container->get(InputTypeGenerator::class), + $container->get(InputTypeUtils::class), + $container->get(BasicAutoWiringContainer::class), + $container->get(AnnotationReader::class), + $container->get(NamingStrategyInterface::class), + $container->get(RecursiveTypeMapperInterface::class), + new Psr16Cache($arrayAdapter), + ); + }, + PorpaginasTypeMapper::class => static function (ContainerInterface $container) { + return new PorpaginasTypeMapper($container->get(RecursiveTypeMapperInterface::class)); + }, + EnumTypeMapper::class => static function (ContainerInterface $container) { + return new EnumTypeMapper( + $container->get(RootTypeMapperInterface::class), + $container->get(AnnotationReader::class), + new ArrayAdapter(), + [ + $container->get(NamespaceFactory::class) + ->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Models'), + ], + ); + }, + TypeGenerator::class => static function (ContainerInterface $container) { + return new TypeGenerator( + $container->get(AnnotationReader::class), + $container->get(NamingStrategyInterface::class), + $container->get(TypeRegistry::class), + $container->get(BasicAutoWiringContainer::class), + $container->get(RecursiveTypeMapperInterface::class), + $container->get(FieldsBuilder::class), + ); + }, + TypeRegistry::class => static function () { + return new TypeRegistry(); + }, + InputTypeGenerator::class => static function (ContainerInterface $container) { + return new InputTypeGenerator( + $container->get(InputTypeUtils::class), + $container->get(FieldsBuilder::class), + ); + }, + InputTypeUtils::class => static function (ContainerInterface $container) { + return new InputTypeUtils( + $container->get(AnnotationReader::class), + $container->get(NamingStrategyInterface::class), + ); + }, + AnnotationReader::class => static function (ContainerInterface $container) { + return new AnnotationReader(new DoctrineAnnotationReader()); + }, + NamingStrategyInterface::class => static function () { + return new NamingStrategy(); + }, + CachedDocBlockFactory::class => static function () { + $arrayAdapter = new ArrayAdapter(); + $arrayAdapter->setLogger(new ExceptionLogger()); + return new CachedDocBlockFactory(new Psr16Cache($arrayAdapter)); + }, + RootTypeMapperInterface::class => static function (ContainerInterface $container) { + return new VoidTypeMapper( + new NullableTypeMapperAdapter( + $container->get('topRootTypeMapper') + ) + ); + }, + 'topRootTypeMapper' => static function () { + return new LastDelegatingTypeMapper(); + }, + 'rootTypeMapper' => static function (ContainerInterface $container) { + // These are in reverse order of execution + $errorRootTypeMapper = new FinalRootTypeMapper($container->get(RecursiveTypeMapperInterface::class)); + $rootTypeMapper = new BaseTypeMapper($errorRootTypeMapper, $container->get(RecursiveTypeMapperInterface::class), $container->get(RootTypeMapperInterface::class)); + $rootTypeMapper = new MyCLabsEnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), new ArrayAdapter(), [ $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models') ]); + if (interface_exists(UnitEnum::class)) { + $rootTypeMapper = new EnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), new ArrayAdapter(), [ $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Models') ]); + } + $rootTypeMapper = new CompoundTypeMapper($rootTypeMapper, $container->get(RootTypeMapperInterface::class), $container->get(NamingStrategyInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class)); + $rootTypeMapper = new IteratorTypeMapper($rootTypeMapper, $container->get(RootTypeMapperInterface::class)); + return $rootTypeMapper; + }, + ContainerParameterHandler::class => static function (ContainerInterface $container) { + return new ContainerParameterHandler($container, true, true); + }, + InjectUserParameterHandler::class => static function (ContainerInterface $container) { + return new InjectUserParameterHandler($container->get(AuthenticationServiceInterface::class)); + }, + 'testService' => static function () { + return 'foo'; + }, + stdClass::class => static function () { + // Empty test service for autowiring + return new stdClass(); + }, + ParameterMiddlewareInterface::class => static function (ContainerInterface $container) { + $parameterMiddlewarePipe = new ParameterMiddlewarePipe(); + $parameterMiddlewarePipe->pipe(new ResolveInfoParameterHandler()); + $parameterMiddlewarePipe->pipe($container->get(ContainerParameterHandler::class)); + $parameterMiddlewarePipe->pipe($container->get(InjectUserParameterHandler::class)); + + return $parameterMiddlewarePipe; + }, + ]; + + if (interface_exists(UnitEnum::class)) { + // Register another instance of GlobTypeMapper to process our PHP 8.1 enums and/or other + // 8.1 supported features. + $services[GlobTypeMapper::class . '3'] = static function (ContainerInterface $container) { + $arrayAdapter = new ArrayAdapter(); + $arrayAdapter->setLogger(new ExceptionLogger()); + return new GlobTypeMapper( + $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Models'), + $container->get(TypeGenerator::class), + $container->get(InputTypeGenerator::class), + $container->get(InputTypeUtils::class), + $container->get(BasicAutoWiringContainer::class), + $container->get(AnnotationReader::class), + $container->get(NamingStrategyInterface::class), + $container->get(RecursiveTypeMapperInterface::class), + new Psr16Cache($arrayAdapter), + ); + }; + } + + $container = new LazyContainer($overloadedServices + $services); + $container->get(TypeResolver::class)->registerSchema($container->get(Schema::class)); + $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(GlobTypeMapper::class)); + $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(GlobTypeMapper::class . '2')); + if (interface_exists(UnitEnum::class)) { + $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(GlobTypeMapper::class . '3')); + } + $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(PorpaginasTypeMapper::class)); + + $container->get('topRootTypeMapper')->setNext($container->get('rootTypeMapper')); + /*$container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new CompoundTypeMapper($container->get(RootTypeMapperInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class))); + $container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new IteratorTypeMapper($container->get(RootTypeMapperInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class))); + $container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new IteratorTypeMapper($container->get(RootTypeMapperInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class))); + $container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new MyCLabsEnumTypeMapper()); + $container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new BaseTypeMapper($container->get(RecursiveTypeMapperInterface::class), $container->get(RootTypeMapperInterface::class))); +*/ + return $container; + } + + protected function getSuccessResult(ExecutionResult $result, int $debugFlag = DebugFlag::RETHROW_INTERNAL_EXCEPTIONS): mixed + { + $array = $result->toArray($debugFlag); + if (isset($array['errors']) || ! isset($array['data'])) { + $this->fail('Expected a successful answer. Got ' . json_encode($array, JSON_PRETTY_PRINT)); + } + return $array['data']; + } +} \ No newline at end of file diff --git a/tests/Integration/QueryComplexityTest.php b/tests/Integration/QueryComplexityTest.php new file mode 100644 index 0000000000..c3af9d04f8 --- /dev/null +++ b/tests/Integration/QueryComplexityTest.php @@ -0,0 +1,201 @@ +mainContainer->get(Schema::class); + assert($schema instanceof Schema); + + $result = GraphQL::executeQuery( + $schema, + <<<'GRAPHQL' + query { + articles { + title + } + } + GRAPHQL, + validationRules: [ + ...DocumentValidator::allRules(), + new QueryComplexity(60), + ] + ); + + $this->assertSame([ + ['title' => 'Title'] + ], $this->getSuccessResult($result)['articles']); + } + + public function testExceedsAllowedQueryComplexity(): void + { + $schema = $this->mainContainer->get(Schema::class); + assert($schema instanceof Schema); + + $result = GraphQL::executeQuery( + $schema, + <<<'GRAPHQL' + query { + articles { + title + } + } + GRAPHQL, + validationRules: [ + ...DocumentValidator::allRules(), + new QueryComplexity(5), + ] + ); + + $this->assertSame('Max query complexity should be 5 but got 60.', $result->toArray(DebugFlag::RETHROW_UNSAFE_EXCEPTIONS)['errors'][0]['message']); + } + + /** + * @dataProvider calculatesCorrectQueryCostProvider + */ + public function testCalculatesCorrectQueryCost(int $expectedCost, string $query): void + { + $schema = $this->mainContainer->get(Schema::class); + assert($schema instanceof Schema); + + $result = GraphQL::executeQuery( + $schema, + $query, + validationRules: [ + ...DocumentValidator::allRules(), + new QueryComplexity(1), + ] + ); + + $this->assertSame('Max query complexity should be 1 but got ' . $expectedCost . '.', $result->toArray(DebugFlag::RETHROW_UNSAFE_EXCEPTIONS)['errors'][0]['message']); + } + + public static function calculatesCorrectQueryCostProvider(): iterable + { + yield [ + 60, // 10 articles * (5 in controller + 1 for title) + <<<'GRAPHQL' + query { + articles { + title + } + } + GRAPHQL, + ]; + + yield [ + 110, // 10 articles * (5 in controller + 1 for title + 5 for description) + <<<'GRAPHQL' + query { + articles { + title + comment + } + } + GRAPHQL, + ]; + + yield [ + 100, // 10 articles * (5 in controller + 1 for title + 3 for author + 1 for author name) + <<<'GRAPHQL' + query { + articles { + title + author { + name + } + } + } + GRAPHQL, + ]; + + yield [ + 18, // 3 articles * (5 in controller + 1 for title) + <<<'GRAPHQL' + query { + articles(take: 3) { + title + } + } + GRAPHQL, + ]; + + yield [ + 3000, // 500 articles default multiplier * (5 in controller + 1 for title) + <<<'GRAPHQL' + query { + articles(take: null) { + title + } + } + GRAPHQL, + ]; + } + + /** + * @dataProvider reportsQueryCostInIntrospectionProvider + */ + public function testReportsQueryCostInIntrospection(string|null $expectedDescription, string $typeName, string $fieldName): void + { + $schema = $this->mainContainer->get(Schema::class); + assert($schema instanceof Schema); + + $result = GraphQL::executeQuery( + $schema, + <<<'GRAPHQL' + query fieldDescription($type: String!) { + __type(name: $type) { + fields { + name + description + } + } + } + GRAPHQL, + variableValues: ['type' => $typeName], + ); + + $data = $result->toArray(DebugFlag::RETHROW_UNSAFE_EXCEPTIONS); + + $fieldsByName = array_filter($data['data']['__type']['fields'], fn (array $field) => $field['name'] === $fieldName); + $fieldByName = reset($fieldsByName); + + self::assertNotNull($fieldByName); + self::assertSame($expectedDescription, $fieldByName['description']); + } + + public static function reportsQueryCostInIntrospectionProvider(): iterable + { + yield [ + 'Cost: complexity = 5, multipliers = [take], defaultMultiplier = 500', + 'Query', + 'articles', + ]; + + yield [ + null, + 'Post', + 'title', + ]; + + yield [ + 'Cost: complexity = 5, multipliers = [], defaultMultiplier = null', + 'Post', + 'comment', + ]; + + yield [ + 'Cost: complexity = 3, multipliers = [], defaultMultiplier = null', + 'Post', + 'author', + ]; + } +} \ No newline at end of file diff --git a/tests/Mappers/Parameters/PrefetchParameterMiddlewareTest.php b/tests/Mappers/Parameters/PrefetchParameterMiddlewareTest.php index ea749d6de8..bc40294dbd 100644 --- a/tests/Mappers/Parameters/PrefetchParameterMiddlewareTest.php +++ b/tests/Mappers/Parameters/PrefetchParameterMiddlewareTest.php @@ -51,6 +51,7 @@ public function testMapsToPrefetchDataParameter(): void { $parameterizedCallableResolver = $this->createMock(ParameterizedCallableResolver::class); $parameterizedCallableResolver + ->expects($this->once()) ->method('resolve') ->with('dummy', new IsEqual(new ReflectionClass(self::class)), 1) ->willReturn([ @@ -83,6 +84,7 @@ public function testRethrowsInvalidCallableAsInvalidPrefetchException(): void $parameterizedCallableResolver = $this->createMock(ParameterizedCallableResolver::class); $parameterizedCallableResolver + ->expects($this->once()) ->method('resolve') ->with([TestType::class, 'notExists'], new IsEqual(new ReflectionClass(self::class)), 1) ->willThrowException(InvalidCallableRuntimeException::methodNotFound(TestType::class, 'notExists')); diff --git a/tests/Middlewares/CostFieldMiddlewareTest.php b/tests/Middlewares/CostFieldMiddlewareTest.php new file mode 100644 index 0000000000..863fbdb89e --- /dev/null +++ b/tests/Middlewares/CostFieldMiddlewareTest.php @@ -0,0 +1,163 @@ +stubField(); + + $result = (new CostFieldMiddleware())->process($this->stubDescriptor([]), $this->stubFieldHandler($field)); + + self::assertSame($field, $result); + } + + public function testIgnoresNullField(): void + { + $result = (new CostFieldMiddleware())->process($this->stubDescriptor([]), $this->stubFieldHandler(null)); + + self::assertNull($result); + } + + /** + * @dataProvider setsComplexityFunctionProvider + */ + public function testSetsComplexityFunction(int $expectedComplexity, Cost $cost): void + { + $field = $this->stubField(); + + $result = (new CostFieldMiddleware())->process($this->stubDescriptor([$cost]), $this->stubFieldHandler($field)); + + self::assertNotNull($result->complexityFn); + + $resultComplexity = ($result->complexityFn)(8, [ + 'take' => 10, + 'null' => null, + ]); + + self::assertSame($expectedComplexity, $resultComplexity); + } + + public static function setsComplexityFunctionProvider(): iterable + { + yield 'default 1 + children 8 #1' => [9, new Cost()]; + yield 'default 1 + children 8 #2' => [9, new Cost(defaultMultiplier: 100)]; + yield 'default 1 + children 8 #3' => [9, new Cost(multipliers: ['null'])]; + yield 'default 1 + children 8 #4' => [9, new Cost(multipliers: ['missing'])]; + + yield 'set 3 + children 8 #1' => [11, new Cost(complexity: 3)]; + yield 'set 3 + children 8 #2' => [11, new Cost(complexity: 3, defaultMultiplier: 100)]; + yield 'set 3 + children 8 #3' => [11, new Cost(complexity: 3, multipliers: ['null'])]; + yield 'set 3 + children 8 #4' => [11, new Cost(complexity: 3, multipliers: ['missing'])]; + + yield 'take 10 * (default 1 + children 8) #1' => [90, new Cost(multipliers: ['take'])]; + yield 'take 10 * (default 1 + children 8) #2' => [90, new Cost(multipliers: ['take'], defaultMultiplier: 100)]; + yield 'take 10 * (default 1 + children 8) #3' => [90, new Cost(multipliers: ['take', 'null'])]; + yield 'take 10 * (default 1 + children 8) #4' => [90, new Cost(multipliers: ['take', 'null'], defaultMultiplier: 100)]; + + yield 'take 10 * (set 3 + children 8) #1' => [110, new Cost(complexity: 3, multipliers: ['take'])]; + yield 'take 10 * (set 3 + children 8) #2' => [110, new Cost(complexity: 3, multipliers: ['take'], defaultMultiplier: 100)]; + yield 'take 10 * (set 3 + children 8) #3' => [110, new Cost(complexity: 3, multipliers: ['take', 'null'])]; + yield 'take 10 * (set 3 + children 8) #4' => [110, new Cost(complexity: 3, multipliers: ['take', 'null'], defaultMultiplier: 100)]; + + yield 'default multiplier 100 * (default 1 + children 8) #1' => [900, new Cost(multipliers: ['null'], defaultMultiplier: 100)]; + yield 'default multiplier 100 * (default 1 + children 8) #2' => [900, new Cost(multipliers: ['missing'], defaultMultiplier: 100)]; + yield 'default multiplier 100 * (default 1 + children 8) #3' => [900, new Cost(multipliers: ['null', 'missing'], defaultMultiplier: 100)]; + + } + + /** + * @dataProvider addsCostInDescriptionProvider + */ + public function testAddsCostInDescription(string $expectedDescription, Cost $cost): void + { + if (Version::series() === '8.5') { + $this->markTestSkipped('Broken on PHPUnit 8.'); + } + + $queryFieldDescriptor = $this->createMock(QueryFieldDescriptor::class); + $queryFieldDescriptor->method('getMiddlewareAnnotations') + ->willReturn(new MiddlewareAnnotations([$cost])); + $queryFieldDescriptor->expects($this->once()) + ->method('withAddedCommentLines') + ->with($expectedDescription) + ->willReturnSelf(); + + (new CostFieldMiddleware())->process($queryFieldDescriptor, $this->stubFieldHandler(null)); + } + + public static function addsCostInDescriptionProvider(): iterable + { + yield [ + 'Cost: complexity = 1, multipliers = [], defaultMultiplier = null', + new Cost(), + ]; + + yield [ + 'Cost: complexity = 5, multipliers = [take], defaultMultiplier = 500', + new Cost(complexity: 5, multipliers: ['take'], defaultMultiplier: 500) + ]; + + yield [ + 'Cost: complexity = 5, multipliers = [take, null], defaultMultiplier = null', + new Cost(complexity: 5, multipliers: ['take', 'null'], defaultMultiplier: null) + ]; + } + + /** + * @param MiddlewareAnnotationInterface[] $annotations + */ + private function stubDescriptor(array $annotations): QueryFieldDescriptor + { + $descriptor = new QueryFieldDescriptor( + name: 'foo', + type: Type::string(), + middlewareAnnotations: new MiddlewareAnnotations($annotations), + ); + $descriptor = $descriptor->withResolver(fn () => self::fail('Should not be called.')); + + return $descriptor; + } + + private function stubFieldHandler(FieldDefinition|null $field): FieldHandlerInterface + { + return new class ($field) implements FieldHandlerInterface { + public function __construct(private readonly FieldDefinition|null $field) + { + } + + public function handle(QueryFieldDescriptor $fieldDescriptor): FieldDefinition|null + { + return $this->field; + } + }; + } + + private function stubField(): FieldDefinition + { + return new FieldDefinition([ + 'name' => 'test', + 'resolve' => function () {}, + ]); + } +} diff --git a/tests/ParameterizedCallableResolverTest.php b/tests/ParameterizedCallableResolverTest.php index dbe8eeb086..f1643fb99e 100644 --- a/tests/ParameterizedCallableResolverTest.php +++ b/tests/ParameterizedCallableResolverTest.php @@ -59,7 +59,8 @@ public function testResolveReturnsCallableAndParametersFromContainer(): void ->willReturn($expectedParameters); $container = $this->createMock(ContainerInterface::class); - $container->method('get') + $container->expects($this->once()) + ->method('get') ->with(FooExtendType::class) ->willReturn(new FooExtendType()); diff --git a/tests/QueryFieldDescriptorTest.php b/tests/QueryFieldDescriptorTest.php index 7e623df62f..fa42c373da 100644 --- a/tests/QueryFieldDescriptorTest.php +++ b/tests/QueryFieldDescriptorTest.php @@ -69,4 +69,25 @@ public function testExceptionInGetOriginalResolver(): void $this->expectException(GraphQLRuntimeException::class); $descriptor->getOriginalResolver(); } + + /** + * @dataProvider withAddedCommentLineProvider + */ + public function testWithAddedCommentLine(string $expected, string|null $previous, string $added): void + { + $descriptor = (new QueryFieldDescriptor( + 'test', + Type::string(), + comment: $previous, + ))->withAddedCommentLines($added); + + self::assertSame($expected, $descriptor->getComment()); + } + + public static function withAddedCommentLineProvider(): iterable + { + yield ['', null, '']; + yield ['Asd', null, 'Asd']; + yield ["Some comment\nAsd", 'Some comment', 'Asd']; + } } diff --git a/website/docs/annotations-reference.md b/website/docs/annotations-reference.md index 334b2864d9..0a1c872661 100644 --- a/website/docs/annotations-reference.md +++ b/website/docs/annotations-reference.md @@ -251,6 +251,16 @@ Attribute | Compulsory | Type | Definition ---------------|------------|------|-------- *for* | *yes* | string | The name of the PHP parameter to hide +## @Cost + +Sets complexity and multipliers on fields for [automatic query complexity](operation-complexity.md#static-request-analysis). + +Attribute | Compulsory | Type | Definition +--------------------|------------|-----------------|----------------------------------------------------------------- +*complexity* | *no* | int | Complexity for that field +*multipliers* | *no* | array\ | Names of fields by value of which complexity will be multiplied +*defaultMultiplier* | *no* | int | Default multiplier value if all multipliers are missing/null + ## @Validate
This annotation is only available in the GraphQLite Laravel package
diff --git a/website/docs/operation-complexity.md b/website/docs/operation-complexity.md new file mode 100644 index 0000000000..bf946721ee --- /dev/null +++ b/website/docs/operation-complexity.md @@ -0,0 +1,223 @@ +--- +id: operation-complexity +title: Operation complexity +sidebar_label: Operation complexity +--- + +At some point you may find yourself receiving queries with an insane amount of requested +fields or items, all at once. Usually, it's not a good thing, so you may want to somehow +limit the amount of requests or their individual complexity. + +## Query depth + +The simplest way to limit complexity is to limit the max query depth. `webonyx/graphql-php`, +which GraphQLite relies on, [has this built in](https://webonyx.github.io/graphql-php/security/#limiting-query-depth). +To use it, you may use `addValidationRule` when building your PSR15 middleware: + +```php +$builder = new Psr15GraphQLMiddlewareBuilder($schema); + +$builder->addValidationRule(new \GraphQL\Validator\Rules\QueryDepth(7)); +``` + +Although this works for simple cases, this doesn't prevent requesting an excessive amount +of fields on the depth of under 7, nor does it prevent requesting too many nodes in paginated lists. +This is where automatic query complexity comes to save us. + +## Static request analysis + +The operation complexity analyzer is a useful tool to make your API secure. The operation +complexity analyzer assigns by default every field a complexity of `1`. The complexity of +all fields in one of the operations of a GraphQL request is not allowed to be greater +than the maximum permitted operation complexity. + +This sounds fairly simple at first, but the more you think about this, the more you +wonder if that is so. Does every field have the same complexity? + +In a data graph, not every field is the same. We have fields that fetch data that are +more expensive than fields that just complete already resolved data. + +```graphql +type Query { + books(take: Int = 10): [Book] +} + +type Book { + title + author: Author +} + +type Author { + name +} +``` + +In the above example executing the `books` field on the `Query` type might go to the +database and fetch the `Book`. This means that the cost of the `books` field is +probably higher than the cost of the `title` field. The cost of the title field +might be the impact on the memory and to the transport. For `title`, the default +cost of `1` os OK. But for `books`, we might want to go with a higher cost of `10` +since we are getting a list of books from our database. + +Moreover, we have the field `author` on the book, which might go to the database +as well to fetch the `Author` object. Since we are only fetching a single item here, +we might want to apply a cost of `5` to this field. + +```php +class Controller { + /** + * @return Book[] + */ + #[Query] + #[Cost(complexity: 10)] + public function books(int $take = 10): array {} +} + +#[Type] +class Book { + #[Field] + public string $title; + + #[Field] + #[Cost(complexity: 5)] + public Author $author; +} + +#[Type] +class Author { + #[Field] + public string $name; +} +``` + +If we run the following query against our data graph, we will come up with the cost of `11`. + +```graphql +query { + books { + title + } +} +``` + +When drilling in further, a cost of `17` occurs. + +```graphql +query { + books { + title + author { + name + } + } +} +``` + +This kind of analysis is entirely static and could just be done by inspecting the +query syntax tree. The impact on the overall execution performance is very low. +But with this static approach, we do have a very rough idea of the performance. +Is it correct to apply always a cost of `10` even though we might get one or one +hundred books back? + +## Full request analysis + +The operation complexity analyzer can also take arguments into account when analyzing operation complexity. + +If we look at our data graph, we can see that the `books` field actually has an argument +that defines how many books are returned. The `take` argument, in this case, specifies +the maximum books that the field will return. + +When measuring the field\`s impact, we can take the argument `take` into account as a +multiplier of our cost. This means we might want to lower the cost to `5` since now we +get a more fine-grained cost calculation by multiplying the complexity +of the field with the `take` argument. + +```php +class Controller { + /** + * @return Book[] + */ + #[Query] + #[Cost(complexity: 5, multipliers: ['take'], defaultMultiplier: 200)] + public function books(?int $take = 10): array {} +} + +#[Type] +class Book { + #[Field] + public string $title; + + #[Field] + #[Cost(complexity: 5)] + public Author $author; +} + +#[Type] +class Author { + #[Field] + public string $name; +} +``` + +With the multiplier in place, we now get a cost of `60` for the request since the multiplier +is applied to the books field and the child fields' cost. If multiple multipliers are specified, +the cost will be multiplied by each of the fields. + +Cost calculation: `10 * (5 + 1)` + +```graphql +query { + books { + title + } +} +``` + +When drilling in further, the cost will go up to `240` since we are now pulling twice as much books and also their authors. + +Cost calculation: `20 * (5 + 1 + 5 + 1)` + +```graphql +query { + books(take: 20) { + title + author { + name + } + } +} +``` + +Notice the nullable `$take` parameter. This might come in handy if `take: null` means "get all items", +but that would also mean that the overall complexity would only be `1 + 5 + 1 + 5 + 1 = 11`, +when in fact that would be a very costly query to execute. + +If all of the multiplier fields are either `null` or missing (and don't have default values), +`defaultMultiplier` is used: + +Cost calculation: `200 * (5 + 1 + 5 + 1)` + +```graphql +query { + books(take: null) { + title + author { + name + } + } +} +``` + +## Setup + +As with query depth, automatic query complexity is configured through PSR15 middleware: + +```php +$builder = new Psr15GraphQLMiddlewareBuilder($schema); + +// Total query cost cannot exceed 1000 points +$builder->limitQueryComplexity(1000); +``` + +Beware that introspection queries would also be limited in complexity. A full introspection +query sits at around `107` points, so we recommend a minimum of `150` for query complexity limit. diff --git a/website/sidebars.json b/website/sidebars.json index 10bea7aae7..68ec666d7f 100755 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -25,7 +25,8 @@ "Security": [ "authentication-authorization", "fine-grained-security", - "implementing-security" + "implementing-security", + "operation-complexity" ], "Performance": [ "query-plan", From 0c2909007451c278bf6722f2637bc6e64109bb04 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Thu, 14 Sep 2023 02:19:28 -0400 Subject: [PATCH 030/108] Improved description formatting for cost complexity (#618) * Add new line for better description formatting * Updated tests * Use double quotes for proper \n parsing --- src/Middlewares/CostFieldMiddleware.php | 2 +- tests/Integration/QueryComplexityTest.php | 8 ++++---- tests/Middlewares/CostFieldMiddlewareTest.php | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Middlewares/CostFieldMiddleware.php b/src/Middlewares/CostFieldMiddleware.php index 56daa62328..3e4865ca49 100644 --- a/src/Middlewares/CostFieldMiddleware.php +++ b/src/Middlewares/CostFieldMiddleware.php @@ -63,7 +63,7 @@ public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandler private function buildQueryComment(Cost $costAttribute): string { - return 'Cost: ' . + return "\nCost: " . implode(', ', [ 'complexity = ' . $costAttribute->complexity, 'multipliers = [' . implode(', ', $costAttribute->multipliers) . ']', diff --git a/tests/Integration/QueryComplexityTest.php b/tests/Integration/QueryComplexityTest.php index c3af9d04f8..fd2fbe95f6 100644 --- a/tests/Integration/QueryComplexityTest.php +++ b/tests/Integration/QueryComplexityTest.php @@ -175,7 +175,7 @@ public function testReportsQueryCostInIntrospection(string|null $expectedDescrip public static function reportsQueryCostInIntrospectionProvider(): iterable { yield [ - 'Cost: complexity = 5, multipliers = [take], defaultMultiplier = 500', + "\nCost: complexity = 5, multipliers = [take], defaultMultiplier = 500", 'Query', 'articles', ]; @@ -187,15 +187,15 @@ public static function reportsQueryCostInIntrospectionProvider(): iterable ]; yield [ - 'Cost: complexity = 5, multipliers = [], defaultMultiplier = null', + "\nCost: complexity = 5, multipliers = [], defaultMultiplier = null", 'Post', 'comment', ]; yield [ - 'Cost: complexity = 3, multipliers = [], defaultMultiplier = null', + "\nCost: complexity = 3, multipliers = [], defaultMultiplier = null", 'Post', 'author', ]; } -} \ No newline at end of file +} diff --git a/tests/Middlewares/CostFieldMiddlewareTest.php b/tests/Middlewares/CostFieldMiddlewareTest.php index 863fbdb89e..c5ddccabc6 100644 --- a/tests/Middlewares/CostFieldMiddlewareTest.php +++ b/tests/Middlewares/CostFieldMiddlewareTest.php @@ -109,17 +109,17 @@ public function testAddsCostInDescription(string $expectedDescription, Cost $cos public static function addsCostInDescriptionProvider(): iterable { yield [ - 'Cost: complexity = 1, multipliers = [], defaultMultiplier = null', + "\nCost: complexity = 1, multipliers = [], defaultMultiplier = null", new Cost(), ]; yield [ - 'Cost: complexity = 5, multipliers = [take], defaultMultiplier = 500', + "\nCost: complexity = 5, multipliers = [take], defaultMultiplier = 500", new Cost(complexity: 5, multipliers: ['take'], defaultMultiplier: 500) ]; yield [ - 'Cost: complexity = 5, multipliers = [take, null], defaultMultiplier = null', + "\nCost: complexity = 5, multipliers = [take, null], defaultMultiplier = null", new Cost(complexity: 5, multipliers: ['take', 'null'], defaultMultiplier: null) ]; } From 53a012dcb15ba3fb0dab5e67926d000952e691d3 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Wed, 20 Sep 2023 09:43:35 -0400 Subject: [PATCH 031/108] Only parse request body for POST requests (#621) --- src/Http/WebonyxGraphqlMiddleware.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Http/WebonyxGraphqlMiddleware.php b/src/Http/WebonyxGraphqlMiddleware.php index 82e0485e7b..ac49cc2939 100644 --- a/src/Http/WebonyxGraphqlMiddleware.php +++ b/src/Http/WebonyxGraphqlMiddleware.php @@ -27,6 +27,7 @@ use function json_last_error; use function json_last_error_msg; use function max; +use function strtolower; use const JSON_ERROR_NONE; @@ -60,7 +61,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface } // Let's json deserialize if this is not already done. - if (empty($request->getParsedBody())) { + if (strtolower($request->getMethod()) === 'post' && empty($request->getParsedBody())) { $content = $request->getBody()->getContents(); $data = json_decode($content, true); @@ -75,8 +76,8 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface if ($context instanceof ResetableContextInterface) { $context->reset(); } + $result = $this->standardServer->executePsrRequest($request); - //return $this->standardServer->processPsrRequest($request, $this->responseFactory->createResponse(), $this->streamFactory->createStream()); return $this->getJsonResponse($this->processResult($result), $this->decideHttpCode($result)); } From 3041608bc6e46fd633f2c823f20cedc0b24adee7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Oct 2023 17:25:48 -0400 Subject: [PATCH 032/108] Bump styfle/cancel-workflow-action from 0.10.1 to 0.12.0 (#624) Bumps [styfle/cancel-workflow-action](https://github.com/styfle/cancel-workflow-action) from 0.10.1 to 0.12.0. - [Release notes](https://github.com/styfle/cancel-workflow-action/releases) - [Commits](https://github.com/styfle/cancel-workflow-action/compare/0.10.1...0.12.0) --- updated-dependencies: - dependency-name: styfle/cancel-workflow-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index cceb787074..2a77ee5f5b 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -24,7 +24,7 @@ jobs: steps: # Cancel previous runs of the same branch - name: cancel - uses: styfle/cancel-workflow-action@0.10.1 + uses: styfle/cancel-workflow-action@0.12.0 with: access_token: ${{ github.token }} From beaac3aa12946640b5c26b56988ec0d870b2d886 Mon Sep 17 00:00:00 2001 From: Mistral Oz <2956113+mistraloz@users.noreply.github.com> Date: Wed, 11 Oct 2023 19:30:27 +0200 Subject: [PATCH 033/108] fix: upgrade @docusaurus/preset-classic from 2.4.1 to 2.4.3 (#627) Snyk has created this PR to upgrade @docusaurus/preset-classic from 2.4.1 to 2.4.3. See this package in npm: https://www.npmjs.com/package/@docusaurus/preset-classic See this project in Snyk: https://app.snyk.io/org/miotcm/project/b0113452-4446-4d25-a677-c7a819a513e5?utm_source=github&utm_medium=referral&page=upgrade-pr Co-authored-by: snyk-bot --- website/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/package.json b/website/package.json index 857c65d2a7..e5c80f758e 100644 --- a/website/package.json +++ b/website/package.json @@ -9,7 +9,7 @@ "devDependencies": {}, "dependencies": { "@docusaurus/core": "2.4.1", - "@docusaurus/preset-classic": "2.4.1", + "@docusaurus/preset-classic": "2.4.3", "clsx": "^1.1.1", "mdx-mermaid": "^1.2.3", "mermaid": "^8.12.0", From d362fcb6d7f1920d61fbdbf874a0bbc48368fade Mon Sep 17 00:00:00 2001 From: Mistral Oz <2956113+mistraloz@users.noreply.github.com> Date: Wed, 11 Oct 2023 19:56:28 +0200 Subject: [PATCH 034/108] fix: upgrade @docusaurus/core from 2.4.1 to 2.4.3 (#626) Snyk has created this PR to upgrade @docusaurus/core from 2.4.1 to 2.4.3. See this package in npm: https://www.npmjs.com/package/@docusaurus/core See this project in Snyk: https://app.snyk.io/org/miotcm/project/b0113452-4446-4d25-a677-c7a819a513e5?utm_source=github&utm_medium=referral&page=upgrade-pr Co-authored-by: snyk-bot Co-authored-by: Jacob Thomason --- website/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/package.json b/website/package.json index e5c80f758e..cc21e6d0e1 100644 --- a/website/package.json +++ b/website/package.json @@ -8,7 +8,7 @@ }, "devDependencies": {}, "dependencies": { - "@docusaurus/core": "2.4.1", + "@docusaurus/core": "2.4.3", "@docusaurus/preset-classic": "2.4.3", "clsx": "^1.1.1", "mdx-mermaid": "^1.2.3", From 812a8302f493535895321fdf0acd151bf4d8914d Mon Sep 17 00:00:00 2001 From: Sergey Malchits <7350510+downace@users.noreply.github.com> Date: Sat, 14 Oct 2023 08:35:21 +0900 Subject: [PATCH 035/108] Add support for arguments description (#623) * Add support for arguments description * Add support for arguments description Pull up `getName` and `getDescription` --------- Co-authored-by: downace --- src/InputTypeUtils.php | 4 +++ src/Mappers/Parameters/TypeHandler.php | 18 ++++++++++++- .../InputTypeParameterInterface.php | 4 +++ tests/FieldsBuilderTest.php | 15 +++++++++++ tests/Fixtures/TestTypeWithDescriptions.php | 24 ++++++++++++++++++ tests/Mappers/Parameters/TypeMapperTest.php | 25 +++++++++++++++++++ tests/QueryFieldTest.php | 13 ++++++++++ 7 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 tests/Fixtures/TestTypeWithDescriptions.php diff --git a/src/InputTypeUtils.php b/src/InputTypeUtils.php index ec155b2481..e99363c0e3 100644 --- a/src/InputTypeUtils.php +++ b/src/InputTypeUtils.php @@ -18,6 +18,7 @@ use ReflectionNamedType; use RuntimeException; use TheCodingMachine\GraphQLite\Parameters\ExpandsInputTypeParameters; +use TheCodingMachine\GraphQLite\Parameters\InputTypeParameter; use TheCodingMachine\GraphQLite\Parameters\InputTypeParameterInterface; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; @@ -144,6 +145,9 @@ public static function getInputTypeArgs(array $args): array if ($parameter->hasDefaultValue()) { $desc['defaultValue'] = $parameter->getDefaultValue(); } + if ($parameter->getDescription()) { + $desc['description'] = $parameter->getDescription(); + } return $desc; }, $inputTypeArgs); diff --git a/src/Mappers/Parameters/TypeHandler.php b/src/Mappers/Parameters/TypeHandler.php index 2a5db4cbd4..d660a52151 100644 --- a/src/Mappers/Parameters/TypeHandler.php +++ b/src/Mappers/Parameters/TypeHandler.php @@ -188,6 +188,8 @@ public function mapParameter(ReflectionParameter $parameter, DocBlock $docBlock, } } + $description = $this->getParameterDescriptionFromDocBlock($docBlock, $parameter); + $hasDefaultValue = false; $defaultValue = null; if ($parameter->allowsNull()) { @@ -201,13 +203,27 @@ public function mapParameter(ReflectionParameter $parameter, DocBlock $docBlock, return new InputTypeParameter( name: $parameter->getName(), type: $type, - description: null, + description: $description, hasDefaultValue: $hasDefaultValue, defaultValue: $defaultValue, argumentResolver: $this->argumentResolver, ); } + private function getParameterDescriptionFromDocBlock(DocBlock $docBlock, ReflectionParameter $parameter): string|null + { + /** @var DocBlock\Tags\Param[] $paramTags */ + $paramTags = $docBlock->getTagsByName('param'); + + foreach ($paramTags as $paramTag) { + if ($paramTag->getVariableName() === $parameter->getName()) { + return $paramTag->getDescription()?->render(); + } + } + + return null; + } + /** * Map class property to a GraphQL type. * diff --git a/src/Parameters/InputTypeParameterInterface.php b/src/Parameters/InputTypeParameterInterface.php index 221640e514..76e7e3f067 100644 --- a/src/Parameters/InputTypeParameterInterface.php +++ b/src/Parameters/InputTypeParameterInterface.php @@ -19,4 +19,8 @@ public function getType(): InputType&Type; public function hasDefaultValue(): bool; public function getDefaultValue(): mixed; + + public function getName(): string; + + public function getDescription(): string; } diff --git a/tests/FieldsBuilderTest.php b/tests/FieldsBuilderTest.php index e0941a9a18..108f87ccd6 100644 --- a/tests/FieldsBuilderTest.php +++ b/tests/FieldsBuilderTest.php @@ -32,6 +32,7 @@ use TheCodingMachine\GraphQLite\Fixtures\TestControllerWithReturnDateTime; use TheCodingMachine\GraphQLite\Fixtures\TestControllerWithUnionInputParam; use TheCodingMachine\GraphQLite\Fixtures\TestEnum; +use TheCodingMachine\GraphQLite\Fixtures\TestTypeWithDescriptions; use TheCodingMachine\GraphQLite\Fixtures\TestTypeWithInvalidPrefetchMethod; use TheCodingMachine\GraphQLite\Fixtures\TestControllerWithInvalidReturnType; use TheCodingMachine\GraphQLite\Fixtures\TestControllerWithIterableReturnType; @@ -708,6 +709,20 @@ public function testPrefetchMethod(): void $this->assertSame('arg4', $testField->args[3]->name); } + public function testOutputTypeArgumentDescription(): void + { + $controller = new TestTypeWithDescriptions(); + + $queryProvider = $this->buildFieldsBuilder(); + + $fields = $queryProvider->getFields($controller); + $testField = $fields['customField']; + + $this->assertCount(1, $testField->args); + $this->assertSame('arg1', $testField->args[0]->name); + $this->assertSame('Test argument description', $testField->args[0]->description); + } + public function testSecurityBadQuery(): void { $controller = new TestControllerWithBadSecurity(); diff --git a/tests/Fixtures/TestTypeWithDescriptions.php b/tests/Fixtures/TestTypeWithDescriptions.php new file mode 100644 index 0000000000..43db24546b --- /dev/null +++ b/tests/Fixtures/TestTypeWithDescriptions.php @@ -0,0 +1,24 @@ +getTest().$arg1; + } +} diff --git a/tests/Mappers/Parameters/TypeMapperTest.php b/tests/Mappers/Parameters/TypeMapperTest.php index 0952e352c3..537038865d 100644 --- a/tests/Mappers/Parameters/TypeMapperTest.php +++ b/tests/Mappers/Parameters/TypeMapperTest.php @@ -14,6 +14,7 @@ use TheCodingMachine\GraphQLite\Fixtures80\UnionOutputType; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException; use TheCodingMachine\GraphQLite\Parameters\DefaultValueParameter; +use TheCodingMachine\GraphQLite\Parameters\InputTypeParameter; use TheCodingMachine\GraphQLite\Reflection\CachedDocBlockFactory; class TypeMapperTest extends AbstractQueryProviderTest @@ -98,6 +99,22 @@ public function testHideParameter(): void $this->assertSame(24, $param->resolve(null, [], null, $resolveInfo)); } + public function testParameterWithDescription(): void + { + $typeMapper = new TypeHandler($this->getArgumentResolver(), $this->getRootTypeMapper(), $this->getTypeResolver()); + + $cachedDocBlockFactory = new CachedDocBlockFactory(new Psr16Cache(new ArrayAdapter())); + + $refMethod = new ReflectionMethod($this, 'withParamDescription'); + $docBlockObj = $cachedDocBlockFactory->getDocBlock($refMethod); + $refParameter = $refMethod->getParameters()[0]; + + $parameter = $typeMapper->mapParameter($refParameter, $docBlockObj, null, $this->getAnnotationReader()->getParameterAnnotations($refParameter)); + $this->assertInstanceOf(InputTypeParameter::class, $parameter); + assert($parameter instanceof InputTypeParameter); + $this->assertEquals('Foo parameter', $parameter->getDescription()); + } + public function testHideParameterException(): void { $typeMapper = new TypeHandler($this->getArgumentResolver(), $this->getRootTypeMapper(), $this->getTypeResolver()); @@ -123,6 +140,14 @@ private function dummy() } + /** + * @param int $foo Foo parameter + */ + private function withParamDescription(int $foo) + { + + } + /** * @HideParameter(for="$foo") */ diff --git a/tests/QueryFieldTest.php b/tests/QueryFieldTest.php index 2503c63351..6a14a5be33 100644 --- a/tests/QueryFieldTest.php +++ b/tests/QueryFieldTest.php @@ -9,8 +9,11 @@ use GraphQL\Type\Definition\Type; use PHPUnit\Framework\TestCase; use TheCodingMachine\GraphQLite\Fixtures\TestObject; +use TheCodingMachine\GraphQLite\Middlewares\ServiceResolver; use TheCodingMachine\GraphQLite\Middlewares\SourceMethodResolver; +use TheCodingMachine\GraphQLite\Parameters\InputTypeParameter; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; +use TheCodingMachine\GraphQLite\Types\ArgumentResolver; class QueryFieldTest extends TestCase { @@ -31,4 +34,14 @@ public function resolve(?object $source, array $args, mixed $context, ResolveInf $this->expectException(Error::class); $resolve(new TestObject('foo'), ['arg' => 12], null, $this->createMock(ResolveInfo::class)); } + + public function testParametersDescription(): void + { + $sourceResolver = new ServiceResolver(static fn () => null); + $queryField = new QueryField('foo', Type::string(), [ + 'arg' => new InputTypeParameter('arg', Type::string(), 'Foo argument', false, null, new ArgumentResolver()), + ], $sourceResolver, $sourceResolver, null, null, []); + + $this->assertEquals('Foo argument', $queryField->args[0]->description); + } } From 3c4045a94c525451fd7ffba4fbfda02c423204d3 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Mon, 16 Oct 2023 13:18:34 -0400 Subject: [PATCH 036/108] Use @param tags from constructor with property promotion (#628) * When using property promotion and needing to add an @param annotation for a property, use the constructor docblock as fallback when an @var isn't available * Fixed tests * Corrected some logic and improved for durability * Added tests * Removed incorrect comment * Check for instanceof Param --- src/FieldsBuilder.php | 7 ++- src/Mappers/Parameters/TypeHandler.php | 24 +++++++++ tests/AbstractQueryProviderTest.php | 11 +++- tests/FieldsBuilderTest.php | 43 +++++++++++++++ .../Fixtures80/PropertyPromotionInputType.php | 22 ++++++++ ...rtyPromotionInputTypeWithoutGenericDoc.php | 20 +++++++ tests/Mappers/Parameters/TypeMapperTest.php | 54 ++++++++++++++----- 7 files changed, 167 insertions(+), 14 deletions(-) create mode 100644 tests/Fixtures80/PropertyPromotionInputType.php create mode 100644 tests/Fixtures80/PropertyPromotionInputTypeWithoutGenericDoc.php diff --git a/src/FieldsBuilder.php b/src/FieldsBuilder.php index 9aa781069a..5cd1d75709 100644 --- a/src/FieldsBuilder.php +++ b/src/FieldsBuilder.php @@ -89,7 +89,12 @@ public function __construct( private readonly InputFieldMiddlewareInterface $inputFieldMiddleware, ) { - $this->typeMapper = new TypeHandler($this->argumentResolver, $this->rootTypeMapper, $this->typeResolver); + $this->typeMapper = new TypeHandler( + $this->argumentResolver, + $this->rootTypeMapper, + $this->typeResolver, + $this->cachedDocBlockFactory, + ); } /** diff --git a/src/Mappers/Parameters/TypeHandler.php b/src/Mappers/Parameters/TypeHandler.php index d660a52151..8a8eadfe1d 100644 --- a/src/Mappers/Parameters/TypeHandler.php +++ b/src/Mappers/Parameters/TypeHandler.php @@ -9,6 +9,7 @@ use GraphQL\Type\Definition\Type as GraphQLType; use InvalidArgumentException; use phpDocumentor\Reflection\DocBlock; +use phpDocumentor\Reflection\DocBlock\Tags\Param; use phpDocumentor\Reflection\DocBlock\Tags\Return_; use phpDocumentor\Reflection\DocBlock\Tags\Var_; use phpDocumentor\Reflection\Fqsen; @@ -41,6 +42,7 @@ use TheCodingMachine\GraphQLite\Parameters\InputTypeParameter; use TheCodingMachine\GraphQLite\Parameters\InputTypeProperty; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; +use TheCodingMachine\GraphQLite\Reflection\CachedDocBlockFactory; use TheCodingMachine\GraphQLite\Types\ArgumentResolver; use TheCodingMachine\GraphQLite\Types\TypeResolver; @@ -66,6 +68,7 @@ public function __construct( private readonly ArgumentResolver $argumentResolver, private readonly RootTypeMapperInterface $rootTypeMapper, private readonly TypeResolver $typeResolver, + private readonly CachedDocBlockFactory $cachedDocBlockFactory, ) { $this->phpDocumentorTypeResolver = new PhpDocumentorTypeResolver(); @@ -124,6 +127,27 @@ private function getDocBlockPropertyType(DocBlock $docBlock, ReflectionProperty $varTags = $docBlock->getTagsByName('var'); if (! $varTags) { + // If we don't have any @var tags, was this property promoted, and if so, do we have an + // @param tag on the constructor docblock? If so, use that for the type. + if ($refProperty->isPromoted()) { + $refConstructor = $refProperty->getDeclaringClass()->getConstructor(); + if (! $refConstructor) { + return null; + } + + $docBlock = $this->cachedDocBlockFactory->getDocBlock($refConstructor); + $paramTags = $docBlock->getTagsByName('param'); + foreach ($paramTags as $paramTag) { + if (! $paramTag instanceof Param) { + continue; + } + + if ($paramTag->getVariableName() === $refProperty->getName()) { + return $paramTag->getType(); + } + } + } + return null; } diff --git a/tests/AbstractQueryProviderTest.php b/tests/AbstractQueryProviderTest.php index 0c888343f8..2f59ff768b 100644 --- a/tests/AbstractQueryProviderTest.php +++ b/tests/AbstractQueryProviderTest.php @@ -277,6 +277,15 @@ protected function getParameterMiddlewarePipe(): ParameterMiddlewarePipe return $this->parameterMiddlewarePipe; } + protected function getCachedDocBlockFactory(): CachedDocBlockFactory + { + $arrayAdapter = new ArrayAdapter(); + $arrayAdapter->setLogger(new ExceptionLogger()); + $psr16Cache = new Psr16Cache($arrayAdapter); + + return new CachedDocBlockFactory($psr16Cache); + } + protected function buildFieldsBuilder(): FieldsBuilder { $arrayAdapter = new ArrayAdapter(); @@ -308,7 +317,7 @@ protected function buildFieldsBuilder(): FieldsBuilder $this->getTypeMapper(), $this->getArgumentResolver(), $this->getTypeResolver(), - new CachedDocBlockFactory($psr16Cache), + $this->getCachedDocBlockFactory(), new NamingStrategy(), $this->buildRootTypeMapper(), $parameterMiddlewarePipe, diff --git a/tests/FieldsBuilderTest.php b/tests/FieldsBuilderTest.php index 108f87ccd6..137a02e72e 100644 --- a/tests/FieldsBuilderTest.php +++ b/tests/FieldsBuilderTest.php @@ -14,6 +14,7 @@ use GraphQL\Type\Definition\StringType; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\UnionType; +use PhpParser\Builder\Property; use ReflectionMethod; use stdClass; use Symfony\Component\Cache\Adapter\ArrayAdapter; @@ -69,6 +70,8 @@ use TheCodingMachine\GraphQLite\Security\VoidAuthenticationService; use TheCodingMachine\GraphQLite\Security\VoidAuthorizationService; use TheCodingMachine\GraphQLite\Annotations\Query; +use TheCodingMachine\GraphQLite\Fixtures80\PropertyPromotionInputType; +use TheCodingMachine\GraphQLite\Fixtures80\PropertyPromotionInputTypeWithoutGenericDoc; use TheCodingMachine\GraphQLite\Types\DateTimeType; use TheCodingMachine\GraphQLite\Types\VoidType; @@ -197,6 +200,46 @@ public function test($typeHintInDocBlock) $this->assertInstanceOf(StringType::class, $query->getType()->getWrappedType()); } + /** + * Tests that the fields builder will fail when a parameter is missing it's generic docblock + * definition, when required - an array, for instance, or could be a collection (List types) + * + * @requires PHP >= 8.0 + */ + public function testTypeMissingForPropertyPromotionWithoutGenericDoc(): void + { + $fieldsBuilder = $this->buildFieldsBuilder(); + + $this->expectException(CannotMapTypeException::class); + + $fieldsBuilder->getInputFields( + PropertyPromotionInputTypeWithoutGenericDoc::class, + 'PropertyPromotionInputTypeWithoutGenericDocInput', + ); + } + + /** + * Tests that the fields builder will properly build an input type using property promotion + * with the generic docblock defined on the constructor and not the property directly. + * + * @requires PHP >= 8.0 + */ + public function testTypeInDocBlockWithPropertyPromotion(): void + { + $fieldsBuilder = $this->buildFieldsBuilder(); + + // Techncially at this point, we already know it's working, since an exception would have been + // thrown otherwise, requiring the generic type to be specified. + // @see self::testTypeMissingForPropertyPromotionWithoutGenericDoc + $inputFields = $fieldsBuilder->getInputFields( + PropertyPromotionInputType::class, + 'PropertyPromotionInputTypeInput', + ); + + $this->assertCount(1, $inputFields); + $this->assertEquals('amounts', reset($inputFields)->name); + } + public function testQueryProviderWithFixedReturnType(): void { $controller = new TestController(); diff --git a/tests/Fixtures80/PropertyPromotionInputType.php b/tests/Fixtures80/PropertyPromotionInputType.php new file mode 100644 index 0000000000..d9850f52a1 --- /dev/null +++ b/tests/Fixtures80/PropertyPromotionInputType.php @@ -0,0 +1,22 @@ + $amounts + */ + public function __construct( + #[GraphQLite\Field] + public array $amounts, + ) {} +} diff --git a/tests/Fixtures80/PropertyPromotionInputTypeWithoutGenericDoc.php b/tests/Fixtures80/PropertyPromotionInputTypeWithoutGenericDoc.php new file mode 100644 index 0000000000..9b1391f7b8 --- /dev/null +++ b/tests/Fixtures80/PropertyPromotionInputTypeWithoutGenericDoc.php @@ -0,0 +1,20 @@ +` + */ + public function __construct( + #[GraphQLite\Field] + public array $amounts, + ) {} +} diff --git a/tests/Mappers/Parameters/TypeMapperTest.php b/tests/Mappers/Parameters/TypeMapperTest.php index 537038865d..6a1a148f45 100644 --- a/tests/Mappers/Parameters/TypeMapperTest.php +++ b/tests/Mappers/Parameters/TypeMapperTest.php @@ -22,7 +22,12 @@ class TypeMapperTest extends AbstractQueryProviderTest public function testMapScalarUnionException(): void { - $typeMapper = new TypeHandler($this->getArgumentResolver(), $this->getRootTypeMapper(), $this->getTypeResolver()); + $typeMapper = new TypeHandler( + $this->getArgumentResolver(), + $this->getRootTypeMapper(), + $this->getTypeResolver(), + $this->getCachedDocBlockFactory(), + ); $cachedDocBlockFactory = new CachedDocBlockFactory(new Psr16Cache(new ArrayAdapter())); @@ -39,9 +44,14 @@ public function testMapScalarUnionException(): void */ public function testMapObjectUnionWorks(): void { - $typeMapper = new TypeHandler($this->getArgumentResolver(), $this->getRootTypeMapper(), $this->getTypeResolver()); + $cachedDocBlockFactory = $this->getCachedDocBlockFactory(); - $cachedDocBlockFactory = new CachedDocBlockFactory(new Psr16Cache(new ArrayAdapter())); + $typeMapper = new TypeHandler( + $this->getArgumentResolver(), + $this->getRootTypeMapper(), + $this->getTypeResolver(), + $cachedDocBlockFactory, + ); $refMethod = new ReflectionMethod(UnionOutputType::class, 'objectUnion'); $docBlockObj = $cachedDocBlockFactory->getDocBlock($refMethod); @@ -61,9 +71,14 @@ public function testMapObjectUnionWorks(): void */ public function testMapObjectNullableUnionWorks(): void { - $typeMapper = new TypeHandler($this->getArgumentResolver(), $this->getRootTypeMapper(), $this->getTypeResolver()); + $cachedDocBlockFactory = $this->getCachedDocBlockFactory(); - $cachedDocBlockFactory = new CachedDocBlockFactory(new Psr16Cache(new ArrayAdapter())); + $typeMapper = new TypeHandler( + $this->getArgumentResolver(), + $this->getRootTypeMapper(), + $this->getTypeResolver(), + $cachedDocBlockFactory, + ); $refMethod = new ReflectionMethod(UnionOutputType::class, 'nullableObjectUnion'); $docBlockObj = $cachedDocBlockFactory->getDocBlock($refMethod); @@ -82,9 +97,14 @@ public function testMapObjectNullableUnionWorks(): void public function testHideParameter(): void { - $typeMapper = new TypeHandler($this->getArgumentResolver(), $this->getRootTypeMapper(), $this->getTypeResolver()); + $cachedDocBlockFactory = $this->getCachedDocBlockFactory(); - $cachedDocBlockFactory = new CachedDocBlockFactory(new Psr16Cache(new ArrayAdapter())); + $typeMapper = new TypeHandler( + $this->getArgumentResolver(), + $this->getRootTypeMapper(), + $this->getTypeResolver(), + $cachedDocBlockFactory, + ); $refMethod = new ReflectionMethod($this, 'withDefaultValue'); $refParameter = $refMethod->getParameters()[0]; @@ -101,9 +121,14 @@ public function testHideParameter(): void public function testParameterWithDescription(): void { - $typeMapper = new TypeHandler($this->getArgumentResolver(), $this->getRootTypeMapper(), $this->getTypeResolver()); + $cachedDocBlockFactory = $this->getCachedDocBlockFactory(); - $cachedDocBlockFactory = new CachedDocBlockFactory(new Psr16Cache(new ArrayAdapter())); + $typeMapper = new TypeHandler( + $this->getArgumentResolver(), + $this->getRootTypeMapper(), + $this->getTypeResolver(), + $cachedDocBlockFactory, + ); $refMethod = new ReflectionMethod($this, 'withParamDescription'); $docBlockObj = $cachedDocBlockFactory->getDocBlock($refMethod); @@ -117,9 +142,14 @@ public function testParameterWithDescription(): void public function testHideParameterException(): void { - $typeMapper = new TypeHandler($this->getArgumentResolver(), $this->getRootTypeMapper(), $this->getTypeResolver()); - - $cachedDocBlockFactory = new CachedDocBlockFactory(new Psr16Cache(new ArrayAdapter())); + $cachedDocBlockFactory = $this->getCachedDocBlockFactory(); + + $typeMapper = new TypeHandler( + $this->getArgumentResolver(), + $this->getRootTypeMapper(), + $this->getTypeResolver(), + $cachedDocBlockFactory, + ); $refMethod = new ReflectionMethod($this, 'withoutDefaultValue'); $refParameter = $refMethod->getParameters()[0]; From c906c6630b676d24fcd077c5f01abfb969727042 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Thu, 19 Oct 2023 22:17:46 -0400 Subject: [PATCH 037/108] Removed all conditional logic for tests supporting PHP versions less than 8.1 (#630) --- src/SchemaFactory.php | 11 +-- tests/AbstractQueryProviderTest.php | 15 ++-- tests/AnnotationReaderTest.php | 15 ---- tests/Annotations/DecorateTest.php | 3 - tests/Annotations/FailWithTest.php | 3 - tests/Annotations/RightTest.php | 3 - tests/Annotations/UseInputTypeTest.php | 3 - tests/FieldsBuilderTest.php | 8 +- .../Controllers/ButtonController.php | 10 +-- .../Integration/Models/Button.php | 2 +- .../Integration/Models/Color.php | 2 +- .../Integration/Models/Position.php | 2 +- .../Integration/Models/Size.php | 2 +- .../PropertyPromotionInputType.php | 2 +- ...rtyPromotionInputTypeWithoutGenericDoc.php | 2 +- .../UnionOutputType.php | 2 +- tests/Integration/EndToEndTest.php | 6 +- tests/Integration/IntegrationTestCase.php | 74 ++++--------------- tests/Mappers/Parameters/TypeMapperTest.php | 9 +-- tests/Types/InputTypeTest.php | 3 - 20 files changed, 44 insertions(+), 133 deletions(-) rename tests/{Fixtures81 => Fixtures}/Integration/Controllers/ButtonController.php (66%) rename tests/{Fixtures81 => Fixtures}/Integration/Models/Button.php (92%) rename tests/{Fixtures81 => Fixtures}/Integration/Models/Color.php (75%) rename tests/{Fixtures81 => Fixtures}/Integration/Models/Position.php (68%) rename tests/{Fixtures81 => Fixtures}/Integration/Models/Size.php (67%) rename tests/{Fixtures80 => Fixtures}/PropertyPromotionInputType.php (86%) rename tests/{Fixtures80 => Fixtures}/PropertyPromotionInputTypeWithoutGenericDoc.php (88%) rename tests/{Fixtures80 => Fixtures}/UnionOutputType.php (88%) diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index 11dcdf71f7..2e7ac5e746 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -381,14 +381,9 @@ public function createSchema(): Schema $errorRootTypeMapper = new FinalRootTypeMapper($recursiveTypeMapper); $rootTypeMapper = new BaseTypeMapper($errorRootTypeMapper, $recursiveTypeMapper, $topRootTypeMapper); - - if (interface_exists(UnitEnum::class)) { - $rootTypeMapper = new EnumTypeMapper($rootTypeMapper, $annotationReader, $symfonyCache, $nsList); - } - - if (class_exists(Enum::class)) { - $rootTypeMapper = new MyCLabsEnumTypeMapper($rootTypeMapper, $annotationReader, $symfonyCache, $nsList); - } + $rootTypeMapper = new EnumTypeMapper($rootTypeMapper, $annotationReader, $symfonyCache, $nsList); + // Annotation support - deprecated + $rootTypeMapper = new MyCLabsEnumTypeMapper($rootTypeMapper, $annotationReader, $symfonyCache, $nsList); if (! empty($this->rootTypeMapperFactories)) { $rootSchemaFactoryContext = new RootTypeMapperFactoryContext( diff --git a/tests/AbstractQueryProviderTest.php b/tests/AbstractQueryProviderTest.php index 2f59ff768b..16b6de62d5 100644 --- a/tests/AbstractQueryProviderTest.php +++ b/tests/AbstractQueryProviderTest.php @@ -356,6 +356,7 @@ protected function buildRootTypeMapper(): RootTypeMapperInterface $topRootTypeMapper ); + // Annotation support - deprecated $rootTypeMapper = new MyCLabsEnumTypeMapper( $rootTypeMapper, $this->getAnnotationReader(), @@ -363,14 +364,12 @@ protected function buildRootTypeMapper(): RootTypeMapperInterface [] ); - if (interface_exists(UnitEnum::class)) { - $rootTypeMapper = new EnumTypeMapper( - $rootTypeMapper, - $this->getAnnotationReader(), - $arrayAdapter, - [] - ); - } + $rootTypeMapper = new EnumTypeMapper( + $rootTypeMapper, + $this->getAnnotationReader(), + $arrayAdapter, + [] + ); $rootTypeMapper = new CompoundTypeMapper( $rootTypeMapper, diff --git a/tests/AnnotationReaderTest.php b/tests/AnnotationReaderTest.php index bb57bd6714..a476222c27 100644 --- a/tests/AnnotationReaderTest.php +++ b/tests/AnnotationReaderTest.php @@ -154,9 +154,6 @@ public function testEmptyGetParameterAnnotations(): void $this->assertEmpty($annotationReader->getParameterAnnotationsPerParameter([])); } - /** - * @requires PHP >= 8.0 - */ public function testPhp8AttributeClassAnnotation(): void { $annotationReader = new AnnotationReader(new DoctrineAnnotationReader()); @@ -169,9 +166,6 @@ public function testPhp8AttributeClassAnnotation(): void //$this->assertSame($type, $type2, 'Assert some cache is available'); } - /** - * @requires PHP >= 8.0 - */ public function testPhp8AttributeClassAnnotations(): void { $annotationReader = new AnnotationReader(new DoctrineAnnotationReader()); @@ -181,9 +175,6 @@ public function testPhp8AttributeClassAnnotations(): void $this->assertCount(3, $types); } - /** - * @requires PHP >= 8.0 - */ public function testPhp8AttributeMethodAnnotation(): void { $annotationReader = new AnnotationReader(new DoctrineAnnotationReader()); @@ -192,9 +183,6 @@ public function testPhp8AttributeMethodAnnotation(): void $this->assertInstanceOf(Field::class, $type); } - /** - * @requires PHP >= 8.0 - */ public function testPhp8AttributeMethodAnnotations(): void { $annotationReader = new AnnotationReader(new DoctrineAnnotationReader()); @@ -209,9 +197,6 @@ public function testPhp8AttributeMethodAnnotations(): void $this->assertTrue($securitys[1]->isFailWithSet()); } - /** - * @requires PHP >= 8.0 - */ public function testPhp8AttributeParameterAnnotations(): void { $annotationReader = new AnnotationReader(new DoctrineAnnotationReader()); diff --git a/tests/Annotations/DecorateTest.php b/tests/Annotations/DecorateTest.php index 16f82d5e53..083fff18d1 100644 --- a/tests/Annotations/DecorateTest.php +++ b/tests/Annotations/DecorateTest.php @@ -16,9 +16,6 @@ public function testException(): void new Decorate([]); } - /** - * @requires PHP >= 8.0 - */ public function testPhp8Annotation(): void { $method = new ReflectionMethod(__CLASS__, 'method1'); diff --git a/tests/Annotations/FailWithTest.php b/tests/Annotations/FailWithTest.php index 720d24ae3e..79842c7029 100644 --- a/tests/Annotations/FailWithTest.php +++ b/tests/Annotations/FailWithTest.php @@ -16,9 +16,6 @@ public function testException(): void new FailWith([]); } - /** - * @requires PHP >= 8.0 - */ public function testPhp8Annotation(): void { $method = new ReflectionMethod(__CLASS__, 'method1'); diff --git a/tests/Annotations/RightTest.php b/tests/Annotations/RightTest.php index 224aa65a31..4ca7fa86ed 100644 --- a/tests/Annotations/RightTest.php +++ b/tests/Annotations/RightTest.php @@ -16,9 +16,6 @@ public function testException(): void new Right([]); } - /** - * @requires PHP >= 8.0 - */ public function testPhp8Annotation(): void { $method = new ReflectionMethod(__CLASS__, 'method1'); diff --git a/tests/Annotations/UseInputTypeTest.php b/tests/Annotations/UseInputTypeTest.php index f568640e05..47cb85b329 100644 --- a/tests/Annotations/UseInputTypeTest.php +++ b/tests/Annotations/UseInputTypeTest.php @@ -23,9 +23,6 @@ public function testException2(): void (new UseInputType(['inputType' => 'foo']))->getTarget(); } - /** - * @requires PHP >= 8.0 - */ public function testPhp8Annotation(): void { $attribute = new UseInputType('foo'); diff --git a/tests/FieldsBuilderTest.php b/tests/FieldsBuilderTest.php index 137a02e72e..9f25172526 100644 --- a/tests/FieldsBuilderTest.php +++ b/tests/FieldsBuilderTest.php @@ -70,8 +70,8 @@ use TheCodingMachine\GraphQLite\Security\VoidAuthenticationService; use TheCodingMachine\GraphQLite\Security\VoidAuthorizationService; use TheCodingMachine\GraphQLite\Annotations\Query; -use TheCodingMachine\GraphQLite\Fixtures80\PropertyPromotionInputType; -use TheCodingMachine\GraphQLite\Fixtures80\PropertyPromotionInputTypeWithoutGenericDoc; +use TheCodingMachine\GraphQLite\Fixtures\PropertyPromotionInputType; +use TheCodingMachine\GraphQLite\Fixtures\PropertyPromotionInputTypeWithoutGenericDoc; use TheCodingMachine\GraphQLite\Types\DateTimeType; use TheCodingMachine\GraphQLite\Types\VoidType; @@ -203,8 +203,6 @@ public function test($typeHintInDocBlock) /** * Tests that the fields builder will fail when a parameter is missing it's generic docblock * definition, when required - an array, for instance, or could be a collection (List types) - * - * @requires PHP >= 8.0 */ public function testTypeMissingForPropertyPromotionWithoutGenericDoc(): void { @@ -221,8 +219,6 @@ public function testTypeMissingForPropertyPromotionWithoutGenericDoc(): void /** * Tests that the fields builder will properly build an input type using property promotion * with the generic docblock defined on the constructor and not the property directly. - * - * @requires PHP >= 8.0 */ public function testTypeInDocBlockWithPropertyPromotion(): void { diff --git a/tests/Fixtures81/Integration/Controllers/ButtonController.php b/tests/Fixtures/Integration/Controllers/ButtonController.php similarity index 66% rename from tests/Fixtures81/Integration/Controllers/ButtonController.php rename to tests/Fixtures/Integration/Controllers/ButtonController.php index 6b0eca2e7d..6d99488d05 100644 --- a/tests/Fixtures81/Integration/Controllers/ButtonController.php +++ b/tests/Fixtures/Integration/Controllers/ButtonController.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace TheCodingMachine\GraphQLite\Fixtures81\Integration\Controllers; +namespace TheCodingMachine\GraphQLite\Fixtures\Integration\Controllers; use TheCodingMachine\GraphQLite\Annotations\Mutation; use TheCodingMachine\GraphQLite\Annotations\Query; -use TheCodingMachine\GraphQLite\Fixtures81\Integration\Models\Button; -use TheCodingMachine\GraphQLite\Fixtures81\Integration\Models\Color; -use TheCodingMachine\GraphQLite\Fixtures81\Integration\Models\Position; -use TheCodingMachine\GraphQLite\Fixtures81\Integration\Models\Size; +use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Button; +use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Color; +use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Position; +use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Size; use TheCodingMachine\GraphQLite\Types\ID; final class ButtonController diff --git a/tests/Fixtures81/Integration/Models/Button.php b/tests/Fixtures/Integration/Models/Button.php similarity index 92% rename from tests/Fixtures81/Integration/Models/Button.php rename to tests/Fixtures/Integration/Models/Button.php index 9202216090..4685ac00a3 100644 --- a/tests/Fixtures81/Integration/Models/Button.php +++ b/tests/Fixtures/Integration/Models/Button.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TheCodingMachine\GraphQLite\Fixtures81\Integration\Models; +namespace TheCodingMachine\GraphQLite\Fixtures\Integration\Models; use TheCodingMachine\GraphQLite\Annotations\Field; use TheCodingMachine\GraphQLite\Annotations\Type; diff --git a/tests/Fixtures81/Integration/Models/Color.php b/tests/Fixtures/Integration/Models/Color.php similarity index 75% rename from tests/Fixtures81/Integration/Models/Color.php rename to tests/Fixtures/Integration/Models/Color.php index 1d6c4d4a1a..dabc27a518 100644 --- a/tests/Fixtures81/Integration/Models/Color.php +++ b/tests/Fixtures/Integration/Models/Color.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TheCodingMachine\GraphQLite\Fixtures81\Integration\Models; +namespace TheCodingMachine\GraphQLite\Fixtures\Integration\Models; use TheCodingMachine\GraphQLite\Annotations\Type; diff --git a/tests/Fixtures81/Integration/Models/Position.php b/tests/Fixtures/Integration/Models/Position.php similarity index 68% rename from tests/Fixtures81/Integration/Models/Position.php rename to tests/Fixtures/Integration/Models/Position.php index efd8cdf0db..a24e5645ac 100644 --- a/tests/Fixtures81/Integration/Models/Position.php +++ b/tests/Fixtures/Integration/Models/Position.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TheCodingMachine\GraphQLite\Fixtures81\Integration\Models; +namespace TheCodingMachine\GraphQLite\Fixtures\Integration\Models; use TheCodingMachine\GraphQLite\Annotations\Type; diff --git a/tests/Fixtures81/Integration/Models/Size.php b/tests/Fixtures/Integration/Models/Size.php similarity index 67% rename from tests/Fixtures81/Integration/Models/Size.php rename to tests/Fixtures/Integration/Models/Size.php index 0ed23d2c81..abe9431be0 100644 --- a/tests/Fixtures81/Integration/Models/Size.php +++ b/tests/Fixtures/Integration/Models/Size.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TheCodingMachine\GraphQLite\Fixtures81\Integration\Models; +namespace TheCodingMachine\GraphQLite\Fixtures\Integration\Models; use TheCodingMachine\GraphQLite\Annotations\Type; diff --git a/tests/Fixtures80/PropertyPromotionInputType.php b/tests/Fixtures/PropertyPromotionInputType.php similarity index 86% rename from tests/Fixtures80/PropertyPromotionInputType.php rename to tests/Fixtures/PropertyPromotionInputType.php index d9850f52a1..f3408b5642 100644 --- a/tests/Fixtures80/PropertyPromotionInputType.php +++ b/tests/Fixtures/PropertyPromotionInputType.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TheCodingMachine\GraphQLite\Fixtures80; +namespace TheCodingMachine\GraphQLite\Fixtures; use TheCodingMachine\GraphQLite\Annotations as GraphQLite; diff --git a/tests/Fixtures80/PropertyPromotionInputTypeWithoutGenericDoc.php b/tests/Fixtures/PropertyPromotionInputTypeWithoutGenericDoc.php similarity index 88% rename from tests/Fixtures80/PropertyPromotionInputTypeWithoutGenericDoc.php rename to tests/Fixtures/PropertyPromotionInputTypeWithoutGenericDoc.php index 9b1391f7b8..2309bda29a 100644 --- a/tests/Fixtures80/PropertyPromotionInputTypeWithoutGenericDoc.php +++ b/tests/Fixtures/PropertyPromotionInputTypeWithoutGenericDoc.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TheCodingMachine\GraphQLite\Fixtures80; +namespace TheCodingMachine\GraphQLite\Fixtures; use TheCodingMachine\GraphQLite\Annotations as GraphQLite; diff --git a/tests/Fixtures80/UnionOutputType.php b/tests/Fixtures/UnionOutputType.php similarity index 88% rename from tests/Fixtures80/UnionOutputType.php rename to tests/Fixtures/UnionOutputType.php index 61ec983abb..df50940b5e 100644 --- a/tests/Fixtures80/UnionOutputType.php +++ b/tests/Fixtures/UnionOutputType.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TheCodingMachine\GraphQLite\Fixtures80; +namespace TheCodingMachine\GraphQLite\Fixtures; use TheCodingMachine\GraphQLite\Fixtures\TestObject; use TheCodingMachine\GraphQLite\Fixtures\TestObject2; diff --git a/tests/Integration/EndToEndTest.php b/tests/Integration/EndToEndTest.php index 2e2e4ed847..daa08ceffe 100644 --- a/tests/Integration/EndToEndTest.php +++ b/tests/Integration/EndToEndTest.php @@ -24,9 +24,9 @@ use TheCodingMachine\GraphQLite\FieldsBuilder; use TheCodingMachine\GraphQLite\Fixtures\Inputs\ValidationException; use TheCodingMachine\GraphQLite\Fixtures\Inputs\Validator; -use TheCodingMachine\GraphQLite\Fixtures81\Integration\Models\Color; -use TheCodingMachine\GraphQLite\Fixtures81\Integration\Models\Position; -use TheCodingMachine\GraphQLite\Fixtures81\Integration\Models\Size; +use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Color; +use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Position; +use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Size; use TheCodingMachine\GraphQLite\GlobControllerQueryProvider; use TheCodingMachine\GraphQLite\GraphQLRuntimeException; use TheCodingMachine\GraphQLite\InputTypeGenerator; diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php index e9d843347a..7d1a18ec99 100644 --- a/tests/Integration/IntegrationTestCase.php +++ b/tests/Integration/IntegrationTestCase.php @@ -5,7 +5,6 @@ use Doctrine\Common\Annotations\AnnotationReader as DoctrineAnnotationReader; use GraphQL\Error\DebugFlag; use GraphQL\Executor\ExecutionResult; -use GraphQL\GraphQL; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; use stdClass; @@ -18,19 +17,11 @@ use TheCodingMachine\GraphQLite\Containers\BasicAutoWiringContainer; use TheCodingMachine\GraphQLite\Containers\EmptyContainer; use TheCodingMachine\GraphQLite\Containers\LazyContainer; -use TheCodingMachine\GraphQLite\Context\Context; use TheCodingMachine\GraphQLite\FieldsBuilder; -use TheCodingMachine\GraphQLite\Fixtures\Inputs\ValidationException; -use TheCodingMachine\GraphQLite\Fixtures\Inputs\Validator; -use TheCodingMachine\GraphQLite\Fixtures81\Integration\Models\Color; -use TheCodingMachine\GraphQLite\Fixtures81\Integration\Models\Position; -use TheCodingMachine\GraphQLite\Fixtures81\Integration\Models\Size; use TheCodingMachine\GraphQLite\GlobControllerQueryProvider; -use TheCodingMachine\GraphQLite\GraphQLRuntimeException; use TheCodingMachine\GraphQLite\InputTypeGenerator; use TheCodingMachine\GraphQLite\InputTypeUtils; use TheCodingMachine\GraphQLite\Loggers\ExceptionLogger; -use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException; use TheCodingMachine\GraphQLite\Mappers\CompositeTypeMapper; use TheCodingMachine\GraphQLite\Mappers\GlobTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Parameters\ContainerParameterHandler; @@ -60,7 +51,6 @@ use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewarePipe; use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewareInterface; use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewarePipe; -use TheCodingMachine\GraphQLite\Middlewares\MissingAuthorizationException; use TheCodingMachine\GraphQLite\Middlewares\SecurityFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\SecurityInputFieldMiddleware; use TheCodingMachine\GraphQLite\NamingStrategy; @@ -69,18 +59,15 @@ use TheCodingMachine\GraphQLite\QueryProviderInterface; use TheCodingMachine\GraphQLite\Reflection\CachedDocBlockFactory; use TheCodingMachine\GraphQLite\Schema; -use TheCodingMachine\GraphQLite\SchemaFactory; use TheCodingMachine\GraphQLite\Security\AuthenticationServiceInterface; use TheCodingMachine\GraphQLite\Security\AuthorizationServiceInterface; use TheCodingMachine\GraphQLite\Security\SecurityExpressionLanguageProvider; use TheCodingMachine\GraphQLite\Security\VoidAuthenticationService; use TheCodingMachine\GraphQLite\Security\VoidAuthorizationService; use TheCodingMachine\GraphQLite\TypeGenerator; -use TheCodingMachine\GraphQLite\TypeMismatchRuntimeException; use TheCodingMachine\GraphQLite\TypeRegistry; use TheCodingMachine\GraphQLite\Types\ArgumentResolver; use TheCodingMachine\GraphQLite\Types\TypeResolver; -use TheCodingMachine\GraphQLite\Utils\AccessPropertyException; use TheCodingMachine\GraphQLite\Utils\Namespaces\NamespaceFactory; use UnitEnum; @@ -109,18 +96,17 @@ public function createContainer(array $overloadedServices = []): ContainerInterf new Psr16Cache(new ArrayAdapter()), ); - if (interface_exists(UnitEnum::class)) { - $queryProvider = new AggregateQueryProvider([ - $queryProvider, - new GlobControllerQueryProvider( - 'TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Controllers', - $container->get(FieldsBuilder::class), - $container->get(BasicAutoWiringContainer::class), - $container->get(AnnotationReader::class), - new Psr16Cache(new ArrayAdapter()), - ), - ]); - } + $queryProvider = new AggregateQueryProvider([ + $queryProvider, + new GlobControllerQueryProvider( + 'TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers', + $container->get(FieldsBuilder::class), + $container->get(BasicAutoWiringContainer::class), + $container->get(AnnotationReader::class), + new Psr16Cache(new ArrayAdapter()), + ), + ]); + return $queryProvider; }, FieldsBuilder::class => static function (ContainerInterface $container) { @@ -258,7 +244,7 @@ public function createContainer(array $overloadedServices = []): ContainerInterf new ArrayAdapter(), [ $container->get(NamespaceFactory::class) - ->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Models'), + ->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models'), ], ); }, @@ -313,9 +299,7 @@ public function createContainer(array $overloadedServices = []): ContainerInterf $errorRootTypeMapper = new FinalRootTypeMapper($container->get(RecursiveTypeMapperInterface::class)); $rootTypeMapper = new BaseTypeMapper($errorRootTypeMapper, $container->get(RecursiveTypeMapperInterface::class), $container->get(RootTypeMapperInterface::class)); $rootTypeMapper = new MyCLabsEnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), new ArrayAdapter(), [ $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models') ]); - if (interface_exists(UnitEnum::class)) { - $rootTypeMapper = new EnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), new ArrayAdapter(), [ $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Models') ]); - } + $rootTypeMapper = new EnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), new ArrayAdapter(), [ $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models') ]); $rootTypeMapper = new CompoundTypeMapper($rootTypeMapper, $container->get(RootTypeMapperInterface::class), $container->get(NamingStrategyInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class)); $rootTypeMapper = new IteratorTypeMapper($rootTypeMapper, $container->get(RootTypeMapperInterface::class)); return $rootTypeMapper; @@ -343,42 +327,14 @@ public function createContainer(array $overloadedServices = []): ContainerInterf }, ]; - if (interface_exists(UnitEnum::class)) { - // Register another instance of GlobTypeMapper to process our PHP 8.1 enums and/or other - // 8.1 supported features. - $services[GlobTypeMapper::class . '3'] = static function (ContainerInterface $container) { - $arrayAdapter = new ArrayAdapter(); - $arrayAdapter->setLogger(new ExceptionLogger()); - return new GlobTypeMapper( - $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Models'), - $container->get(TypeGenerator::class), - $container->get(InputTypeGenerator::class), - $container->get(InputTypeUtils::class), - $container->get(BasicAutoWiringContainer::class), - $container->get(AnnotationReader::class), - $container->get(NamingStrategyInterface::class), - $container->get(RecursiveTypeMapperInterface::class), - new Psr16Cache($arrayAdapter), - ); - }; - } - $container = new LazyContainer($overloadedServices + $services); $container->get(TypeResolver::class)->registerSchema($container->get(Schema::class)); $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(GlobTypeMapper::class)); $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(GlobTypeMapper::class . '2')); - if (interface_exists(UnitEnum::class)) { - $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(GlobTypeMapper::class . '3')); - } $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(PorpaginasTypeMapper::class)); $container->get('topRootTypeMapper')->setNext($container->get('rootTypeMapper')); - /*$container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new CompoundTypeMapper($container->get(RootTypeMapperInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class))); - $container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new IteratorTypeMapper($container->get(RootTypeMapperInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class))); - $container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new IteratorTypeMapper($container->get(RootTypeMapperInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class))); - $container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new MyCLabsEnumTypeMapper()); - $container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new BaseTypeMapper($container->get(RecursiveTypeMapperInterface::class), $container->get(RootTypeMapperInterface::class))); -*/ + return $container; } @@ -390,4 +346,4 @@ protected function getSuccessResult(ExecutionResult $result, int $debugFlag = De } return $array['data']; } -} \ No newline at end of file +} diff --git a/tests/Mappers/Parameters/TypeMapperTest.php b/tests/Mappers/Parameters/TypeMapperTest.php index 6a1a148f45..36d66746bc 100644 --- a/tests/Mappers/Parameters/TypeMapperTest.php +++ b/tests/Mappers/Parameters/TypeMapperTest.php @@ -11,7 +11,7 @@ use Symfony\Component\Cache\Psr16Cache; use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; use TheCodingMachine\GraphQLite\Annotations\HideParameter; -use TheCodingMachine\GraphQLite\Fixtures80\UnionOutputType; +use TheCodingMachine\GraphQLite\Fixtures\UnionOutputType; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException; use TheCodingMachine\GraphQLite\Parameters\DefaultValueParameter; use TheCodingMachine\GraphQLite\Parameters\InputTypeParameter; @@ -39,9 +39,6 @@ public function testMapScalarUnionException(): void $typeMapper->mapReturnType($refMethod, $docBlockObj); } - /** - * @requires PHP >= 8.0 - */ public function testMapObjectUnionWorks(): void { $cachedDocBlockFactory = $this->getCachedDocBlockFactory(); @@ -66,9 +63,7 @@ public function testMapObjectUnionWorks(): void $this->assertEquals('TestObject', $unionTypes[0]->name); $this->assertEquals('TestObject2', $unionTypes[1]->name); } - /** - * @requires PHP >= 8.0 - */ + public function testMapObjectNullableUnionWorks(): void { $cachedDocBlockFactory = $this->getCachedDocBlockFactory(); diff --git a/tests/Types/InputTypeTest.php b/tests/Types/InputTypeTest.php index ba36457e97..be46fdc24b 100644 --- a/tests/Types/InputTypeTest.php +++ b/tests/Types/InputTypeTest.php @@ -46,9 +46,6 @@ public function testInputConfiguredCorrectly(): void $this->assertNotInstanceOf(NonNull::class, $fields['bar']->getType()); } - /** - * @requires PHP >= 7.4 - */ public function testInputConfiguredCorrectlyWithTypedProperties(): void { $input = new InputType(TypedFooBar::class, 'TypedFooBarInput', 'Test', false, $this->getFieldsBuilder()); From c6ff429358a6151356511606b47bdc7e932e9ff6 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Fri, 20 Oct 2023 00:36:21 -0400 Subject: [PATCH 038/108] Restore conditional check for MyCLabs enum support --- src/SchemaFactory.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index 2e7ac5e746..28fb46fdaf 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -382,8 +382,11 @@ public function createSchema(): Schema $errorRootTypeMapper = new FinalRootTypeMapper($recursiveTypeMapper); $rootTypeMapper = new BaseTypeMapper($errorRootTypeMapper, $recursiveTypeMapper, $topRootTypeMapper); $rootTypeMapper = new EnumTypeMapper($rootTypeMapper, $annotationReader, $symfonyCache, $nsList); - // Annotation support - deprecated - $rootTypeMapper = new MyCLabsEnumTypeMapper($rootTypeMapper, $annotationReader, $symfonyCache, $nsList); + + if (class_exists(Enum::class)) { + // Annotation support - deprecated + $rootTypeMapper = new MyCLabsEnumTypeMapper($rootTypeMapper, $annotationReader, $symfonyCache, $nsList); + } if (! empty($this->rootTypeMapperFactories)) { $rootSchemaFactoryContext = new RootTypeMapperFactoryContext( From 6cf40ee0dd1b7e5b98cd9b76a435ef639916e0f2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Oct 2023 18:23:58 -0400 Subject: [PATCH 039/108] Bump actions/setup-node from 3 to 4 (#631) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 3 to 4. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/setup-node dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/doc_generation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/doc_generation.yml b/.github/workflows/doc_generation.yml index 019a1ced11..a1e61faa28 100644 --- a/.github/workflows/doc_generation.yml +++ b/.github/workflows/doc_generation.yml @@ -22,7 +22,7 @@ jobs: uses: "actions/checkout@v4" - name: "Setup NodeJS" - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: '16.x' From 1f554090860f1dcbaae90c53bc302543060c3832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Lipu=C5=A1?= Date: Thu, 26 Oct 2023 17:13:58 +0200 Subject: [PATCH 040/108] updated psr/simple-cache dependency to ^3 (#632) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d7be5e4997..82e349b8fb 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "psr/http-message": "^1.0.1", "psr/http-server-handler": "^1", "psr/http-server-middleware": "^1", - "psr/simple-cache": "^1.0.1", + "psr/simple-cache": "^1.0.1 || ^2 || ^3", "symfony/cache": "^4.3 || ^5 || ^6", "symfony/expression-language": "^4 || ^5 || ^6", "thecodingmachine/cache-utils": "^1", From a6851456702164cfb88427967ca72ebf32cdfa4c Mon Sep 17 00:00:00 2001 From: Jakub Janata Date: Thu, 30 Nov 2023 01:05:01 +0100 Subject: [PATCH 041/108] Symfony 7 (#639) --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 82e349b8fb..242e5bb86d 100644 --- a/composer.json +++ b/composer.json @@ -22,8 +22,8 @@ "psr/http-server-handler": "^1", "psr/http-server-middleware": "^1", "psr/simple-cache": "^1.0.1 || ^2 || ^3", - "symfony/cache": "^4.3 || ^5 || ^6", - "symfony/expression-language": "^4 || ^5 || ^6", + "symfony/cache": "^4.3 || ^5 || ^6 || ^7", + "symfony/expression-language": "^4 || ^5 || ^6 || ^7", "thecodingmachine/cache-utils": "^1", "thecodingmachine/class-explorer": "^1.1.0", "webonyx/graphql-php": "^v15.0" @@ -38,7 +38,7 @@ "phpstan/extension-installer": "^1.1", "phpstan/phpstan": "^1.9", "phpunit/phpunit": "^8.5.19 || ^9.5.8", - "symfony/var-dumper": "^5.4 || ^6.0", + "symfony/var-dumper": "^5.4 || ^6.0 || ^7", "thecodingmachine/phpstan-strict-rules": "^1.0" }, "suggest": { From f5e3b449e842a5ffa2da3609b6a8dbe0a7a3fa04 Mon Sep 17 00:00:00 2001 From: Oleksandr Prypkhan Date: Mon, 4 Dec 2023 04:26:09 +0200 Subject: [PATCH 042/108] Allow constructor fields to use middleware (#636) * Allow constructor fields to use middleware * Fix failing tests * Fix failing PHPStan * Add actual tests * Fix missing typehint * Revert source change --- phpstan.neon | 5 +- src/FailedResolvingInputType.php | 4 +- src/FieldsBuilder.php | 171 ++++++++---------- src/InputField.php | 57 +++--- src/InputFieldDescriptor.php | 142 ++------------- .../BadExpressionInSecurityException.php | 4 +- src/Middlewares/MagicPropertyResolver.php | 20 +- src/Middlewares/ResolverInterface.php | 2 +- src/Middlewares/SecurityFieldMiddleware.php | 2 +- .../SecurityInputFieldMiddleware.php | 2 +- src/Middlewares/ServiceResolver.php | 6 + .../SourceConstructorParameterResolver.php | 50 +++++ .../SourceInputPropertyResolver.php | 17 +- src/Middlewares/SourceMethodResolver.php | 16 +- src/Middlewares/SourcePropertyResolver.php | 17 +- src/QueryFieldDescriptor.php | 131 +------------- src/Types/InputType.php | 69 +++---- tests/FieldsBuilderTest.php | 2 +- ... => TestConstructorPromotedProperties.php} | 46 ++--- .../Controllers/ArticleController.php | 10 + .../Integration/Models/UpdateArticleInput.php | 19 ++ tests/Integration/EndToEndTest.php | 40 ++++ .../AuthorizationFieldMiddlewareTest.php | 9 +- .../AuthorizationInputFieldMiddlewareTest.php | 14 +- tests/Middlewares/CostFieldMiddlewareTest.php | 9 +- tests/Middlewares/FieldMiddlewarePipeTest.php | 5 + .../InputFieldMiddlewarePipeTest.php | 8 +- tests/Middlewares/SourceResolverTest.php | 8 +- tests/QueryFieldDescriptorTest.php | 67 +------ tests/QueryFieldTest.php | 3 +- tests/Types/InputTypeTest.php | 32 +++- 31 files changed, 416 insertions(+), 571 deletions(-) create mode 100644 src/Middlewares/SourceConstructorParameterResolver.php rename tests/Fixtures/Inputs/{TestConstructorAndPropertiesInvalid.php => TestConstructorPromotedProperties.php} (53%) create mode 100644 tests/Fixtures/Integration/Models/UpdateArticleInput.php diff --git a/phpstan.neon b/phpstan.neon index b496200bf0..803977c572 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -17,10 +17,7 @@ parameters: - "#Parameter .* of class ReflectionMethod constructor expects string(\\|null)?, object\\|string given.#" - "#PHPDoc tag @throws with type Psr\\\\SimpleCache\\\\InvalidArgumentException is not subtype of Throwable#" - '#Variable \$context might not be defined.#' - # TODO: fix these in the resolver refactor PR that follows; it'll be initialized in the constructor - - '#Class TheCodingMachine\\GraphQLite\\(QueryFieldDescriptor|InputFieldDescriptor) has an uninitialized readonly property \$(originalResolver|resolver)\. Assign it in the constructor.#' - - '#Readonly property TheCodingMachine\\GraphQLite\\(QueryFieldDescriptor|InputFieldDescriptor)::\$(originalResolver|resolver) is assigned outside of the constructor\.#' - - '#Property TheCodingMachine\\GraphQLite\\(QueryFieldDescriptor|InputFieldDescriptor)::\$resolver \(callable\) in isset\(\) is not nullable\.#' + - '#Parameter \#1 \$callable of class TheCodingMachine\\GraphQLite\\Middlewares\\ServiceResolver constructor expects array{object, string}&callable\(\): mixed, array{object, non-empty-string} given.#' - message: '#Parameter .* of class GraphQL\\Error\\Error constructor expects#' path: src/Exceptions/WebonyxErrorHandler.php diff --git a/src/FailedResolvingInputType.php b/src/FailedResolvingInputType.php index ce69807244..3ae93cff10 100644 --- a/src/FailedResolvingInputType.php +++ b/src/FailedResolvingInputType.php @@ -10,9 +10,9 @@ class FailedResolvingInputType extends RuntimeException { - public static function createForMissingConstructorParameter(string $class, string $parameter): self + public static function createForMissingConstructorParameter(\ArgumentCountError $original): self { - return new self(sprintf("Parameter '%s' is missing for class '%s' constructor. It should be mapped as required field.", $parameter, $class)); + return new self(sprintf("%s. It should be mapped as required field.", $original->getMessage()), previous: $original); } public static function createForDecorator(string $class): self diff --git a/src/FieldsBuilder.php b/src/FieldsBuilder.php index 5cd1d75709..f9c7572559 100644 --- a/src/FieldsBuilder.php +++ b/src/FieldsBuilder.php @@ -38,7 +38,13 @@ use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewareInterface; use TheCodingMachine\GraphQLite\Middlewares\InputFieldHandlerInterface; use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewareInterface; +use TheCodingMachine\GraphQLite\Middlewares\MagicPropertyResolver; use TheCodingMachine\GraphQLite\Middlewares\MissingMagicGetException; +use TheCodingMachine\GraphQLite\Middlewares\ServiceResolver; +use TheCodingMachine\GraphQLite\Middlewares\SourceConstructorParameterResolver; +use TheCodingMachine\GraphQLite\Middlewares\SourceInputPropertyResolver; +use TheCodingMachine\GraphQLite\Middlewares\SourceMethodResolver; +use TheCodingMachine\GraphQLite\Middlewares\SourcePropertyResolver; use TheCodingMachine\GraphQLite\Parameters\InputTypeParameterInterface; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; use TheCodingMachine\GraphQLite\Parameters\PrefetchDataParameter; @@ -370,14 +376,6 @@ private function getFieldsByMethodAnnotations(string|object $controller, Reflect $type = $this->typeMapper->mapReturnType($refMethod, $docBlockObj); } - $fieldDescriptor = new QueryFieldDescriptor( - name: $name, - type: $type, - comment: trim($description), - deprecationReason: $this->getDeprecationReason($docBlockObj), - refMethod: $refMethod, - ); - $parameters = $refMethod->getParameters(); if ($injectSource === true) { $firstParameter = array_shift($parameters); @@ -398,19 +396,21 @@ private function getFieldsByMethodAnnotations(string|object $controller, Reflect $args = ['__graphqlite_prefectData' => $prefetchDataParameter, ...$args]; } - $fieldDescriptor = $fieldDescriptor->withParameters($args); + $resolver = is_string($controller) ? + new SourceMethodResolver($refMethod) : + new ServiceResolver([$controller, $methodName]); - if (is_string($controller)) { - $fieldDescriptor = $fieldDescriptor->withTargetMethodOnSource($refMethod->getDeclaringClass()->getName(), $methodName); - } else { - $callable = [$controller, $methodName]; - assert(is_callable($callable)); - $fieldDescriptor = $fieldDescriptor->withCallable($callable); - } - - $fieldDescriptor = $fieldDescriptor - ->withInjectSource($injectSource) - ->withMiddlewareAnnotations($this->annotationReader->getMiddlewareAnnotations($refMethod)); + $fieldDescriptor = new QueryFieldDescriptor( + name: $name, + type: $type, + resolver: $resolver, + originalResolver: $resolver, + parameters: $args, + injectSource: $injectSource, + comment: trim($description), + deprecationReason: $this->getDeprecationReason($docBlockObj), + middlewareAnnotations: $this->annotationReader->getMiddlewareAnnotations($refMethod), + ); $field = $this->fieldMiddleware->process($fieldDescriptor, new class implements FieldHandlerInterface { public function handle(QueryFieldDescriptor $fieldDescriptor): FieldDefinition|null @@ -480,26 +480,22 @@ private function getFieldsByPropertyAnnotations(string|object $controller, Refle assert($type instanceof OutputType); } + $originalResolver = new SourcePropertyResolver($refProperty); + $resolver = is_string($controller) ? + $originalResolver : + fn () => PropertyAccessor::getValue($controller, $refProperty->getName()); + $fieldDescriptor = new QueryFieldDescriptor( name: $name, type: $type, + resolver: $resolver, + originalResolver: $originalResolver, + injectSource: false, comment: trim($description), deprecationReason: $this->getDeprecationReason($docBlock), - refProperty: $refProperty, + middlewareAnnotations: $this->annotationReader->getMiddlewareAnnotations($refProperty), ); - if (is_string($controller)) { - $fieldDescriptor = $fieldDescriptor->withTargetPropertyOnSource($refProperty->getDeclaringClass()->getName(), $refProperty->getName()); - } else { - $fieldDescriptor = $fieldDescriptor->withCallable(static function () use ($controller, $refProperty) { - return PropertyAccessor::getValue($controller, $refProperty->getName()); - }); - } - - $fieldDescriptor = $fieldDescriptor - ->withInjectSource(false) - ->withMiddlewareAnnotations($this->annotationReader->getMiddlewareAnnotations($refProperty)); - $field = $this->fieldMiddleware->process($fieldDescriptor, new class implements FieldHandlerInterface { public function handle(QueryFieldDescriptor $fieldDescriptor): FieldDefinition|null { @@ -597,15 +593,16 @@ private function getQueryFieldsFromSourceFields(array $sourceFields, ReflectionC $type = $this->typeMapper->mapReturnType($refMethod, $docBlockObj); } + $resolver = new SourceMethodResolver($refMethod); + $fieldDescriptor = new QueryFieldDescriptor( name: $sourceField->getName(), type: $type, + resolver: $resolver, + originalResolver: $resolver, parameters: $args, - targetClass: $refMethod->getDeclaringClass()->getName(), - targetMethodOnSource: $methodName, comment: $description, deprecationReason: $deprecationReason ?? null, - refMethod: $refMethod, ); } else { $outputType = $sourceField->getOutputType(); @@ -619,11 +616,13 @@ private function getQueryFieldsFromSourceFields(array $sourceFields, ReflectionC $type = $this->resolvePhpType($phpTypeStr, $refClass, $magicGefRefMethod); } + $resolver = new MagicPropertyResolver($refClass->getName(), $sourceField->getSourceName() ?? $sourceField->getName()); + $fieldDescriptor = new QueryFieldDescriptor( name: $sourceField->getName(), type: $type, - targetClass: $refClass->getName(), - magicProperty: $sourceField->getSourceName() ?? $sourceField->getName(), + resolver: $resolver, + originalResolver: $resolver, comment: $sourceField->getDescription(), ); } @@ -889,27 +888,22 @@ private function getInputFieldsByMethodAnnotations(string|object $controller, Re assert($type instanceof InputType); + $resolver = new SourceMethodResolver($refMethod); + $inputFieldDescriptor = new InputFieldDescriptor( name: $name, type: $type, + resolver: $resolver, + originalResolver: $resolver, parameters: $args, + injectSource: $injectSource, comment: trim($description), - refMethod: $refMethod, + middlewareAnnotations: $this->annotationReader->getMiddlewareAnnotations($refMethod), isUpdate: $isUpdate, + hasDefaultValue: $isUpdate, + defaultValue: $args[$name]->getDefaultValue() ); - $inputFieldDescriptor = $inputFieldDescriptor - ->withHasDefaultValue($isUpdate) - ->withDefaultValue($args[$name]->getDefaultValue()); - $constructerParameters = $this->getClassConstructParameterNames($refClass); - if (!in_array($name, $constructerParameters)) { - $inputFieldDescriptor = $inputFieldDescriptor->withTargetMethodOnSource($refMethod->getDeclaringClass()->getName(), $methodName); - } - - $inputFieldDescriptor = $inputFieldDescriptor - ->withInjectSource($injectSource) - ->withMiddlewareAnnotations($this->annotationReader->getMiddlewareAnnotations($refMethod)); - $field = $this->inputFieldMiddleware->process($inputFieldDescriptor, new class implements InputFieldHandlerInterface { public function handle(InputFieldDescriptor $inputFieldDescriptor): InputField|null { @@ -965,53 +959,38 @@ private function getInputFieldsByPropertyAnnotations(string|object $controller, $description = $inputProperty->getDescription(); } - if (in_array($name, $constructerParameters)) { - $middlewareAnnotations = $this->annotationReader->getPropertyAnnotations($refProperty, MiddlewareAnnotationInterface::class); - if ($middlewareAnnotations !== []) { - throw IncompatibleAnnotationsException::middlewareAnnotationsUnsupported(); - } - // constructor hydrated - $field = new InputField( - $name, - $inputProperty->getType(), - [$inputProperty->getName() => $inputProperty], - null, - null, - trim($description), - $isUpdate, - $inputProperty->hasDefaultValue(), - $inputProperty->getDefaultValue(), - ); - } else { - $type = $inputProperty->getType(); - if (!$inputType && $isUpdate && $type instanceof NonNull) { - $type = $type->getWrappedType(); - } - assert($type instanceof InputType); + $type = $inputProperty->getType(); + if (!$inputType && $isUpdate && $type instanceof NonNull) { + $type = $type->getWrappedType(); + } + assert($type instanceof InputType); + $forConstructorHydration = in_array($name, $constructerParameters); + $resolver = $forConstructorHydration ? + new SourceConstructorParameterResolver($refProperty->getDeclaringClass()->getName(), $refProperty->getName()) : + new SourceInputPropertyResolver($refProperty); - // setters and properties - $inputFieldDescriptor = new InputFieldDescriptor( - name: $inputProperty->getName(), - type: $type, - parameters: [$inputProperty->getName() => $inputProperty], - targetClass: $refProperty->getDeclaringClass()->getName(), - targetPropertyOnSource: $refProperty->getName(), - injectSource: false, - comment: trim($description), - middlewareAnnotations: $this->annotationReader->getMiddlewareAnnotations($refProperty), - refProperty: $refProperty, - isUpdate: $isUpdate, - hasDefaultValue: $inputProperty->hasDefaultValue(), - defaultValue: $inputProperty->getDefaultValue(), - ); + // setters and properties + $inputFieldDescriptor = new InputFieldDescriptor( + name: $inputProperty->getName(), + type: $type, + resolver: $resolver, + originalResolver: $resolver, + parameters: [$inputProperty->getName() => $inputProperty], + injectSource: false, + forConstructorHydration: $forConstructorHydration, + comment: trim($description), + middlewareAnnotations: $this->annotationReader->getMiddlewareAnnotations($refProperty), + isUpdate: $isUpdate, + hasDefaultValue: $inputProperty->hasDefaultValue(), + defaultValue: $inputProperty->getDefaultValue(), + ); - $field = $this->inputFieldMiddleware->process($inputFieldDescriptor, new class implements InputFieldHandlerInterface { - public function handle(InputFieldDescriptor $inputFieldDescriptor): InputField|null - { - return InputField::fromFieldDescriptor($inputFieldDescriptor); - } - }); - } + $field = $this->inputFieldMiddleware->process($inputFieldDescriptor, new class implements InputFieldHandlerInterface { + public function handle(InputFieldDescriptor $inputFieldDescriptor): InputField|null + { + return InputField::fromFieldDescriptor($inputFieldDescriptor); + } + }); if ($field === null) { continue; diff --git a/src/InputField.php b/src/InputField.php index 16ad51cfc6..f2778c47c7 100644 --- a/src/InputField.php +++ b/src/InputField.php @@ -32,16 +32,25 @@ final class InputField extends InputObjectField /** @var callable */ private $resolve; - private bool $forConstructorHydration = false; - /** * @param (Type&InputType) $type * @param array $arguments Indexed by argument name. * @param mixed|null $defaultValue the default value set for this field * @param array{defaultValue?: mixed,description?: string|null,astNode?: InputValueDefinitionNode|null}|null $additionalConfig */ - public function __construct(string $name, InputType $type, array $arguments, ResolverInterface|null $originalResolver, callable|null $resolver, string|null $comment, bool $isUpdate, bool $hasDefaultValue, mixed $defaultValue, array|null $additionalConfig = null) - { + public function __construct( + string $name, + InputType $type, + array $arguments, + ResolverInterface $originalResolver, + callable $resolver, + private bool $forConstructorHydration, + string|null $comment, + bool $isUpdate, + bool $hasDefaultValue, + mixed $defaultValue, + array|null $additionalConfig = null + ) { $config = [ 'name' => $name, 'type' => $type, @@ -52,28 +61,27 @@ public function __construct(string $name, InputType $type, array $arguments, Res $config['defaultValue'] = $defaultValue; } - if ($originalResolver !== null && $resolver !== null) { - $this->resolve = function (object $source, array $args, $context, ResolveInfo $info) use ($arguments, $originalResolver, $resolver) { + $this->resolve = function (object|null $source, array $args, $context, ResolveInfo $info) use ($arguments, $originalResolver, $resolver) { + if ($this->forConstructorHydration) { + $toPassArgs = [ + $arguments[$this->name]->resolve($source, $args, $context, $info) + ]; + } else { $toPassArgs = $this->paramsToArguments($arguments, $source, $args, $context, $info, $resolver); - $result = $resolver($source, ...$toPassArgs); - - try { - $this->assertInputType($result); - } catch (TypeMismatchRuntimeException $e) { - $e->addInfo($this->name, $originalResolver->toString()); - throw $e; - } - - return $result; - }; - } else { - $this->forConstructorHydration = true; - $this->resolve = function (object|null $source, array $args, $context, ResolveInfo $info) use ($arguments) { - $result = $arguments[$this->name]->resolve($source, $args, $context, $info); + } + + $result = $resolver($source, ...$toPassArgs); + + try { $this->assertInputType($result); - return $result; - }; - } + } catch (TypeMismatchRuntimeException $e) { + $e->addInfo($this->name, $originalResolver->toString()); + throw $e; + } + + return $result; + }; + if ($additionalConfig !== null) { if (isset($additionalConfig['astNode'])) { $config['astNode'] = $additionalConfig['astNode']; @@ -126,6 +134,7 @@ private static function fromDescriptor(InputFieldDescriptor $fieldDescriptor): s $fieldDescriptor->getParameters(), $fieldDescriptor->getOriginalResolver(), $fieldDescriptor->getResolver(), + $fieldDescriptor->isForConstructorHydration(), $fieldDescriptor->getComment(), $fieldDescriptor->isUpdate(), $fieldDescriptor->hasDefaultValue(), diff --git a/src/InputFieldDescriptor.php b/src/InputFieldDescriptor.php index a3eff9abd0..5dd436994d 100644 --- a/src/InputFieldDescriptor.php +++ b/src/InputFieldDescriptor.php @@ -9,10 +9,13 @@ use ReflectionMethod; use ReflectionProperty; use TheCodingMachine\GraphQLite\Annotations\MiddlewareAnnotations; +use TheCodingMachine\GraphQLite\Middlewares\MagicPropertyResolver; use TheCodingMachine\GraphQLite\Middlewares\ResolverInterface; use TheCodingMachine\GraphQLite\Middlewares\ServiceResolver; +use TheCodingMachine\GraphQLite\Middlewares\SourceConstructorParameterResolver; use TheCodingMachine\GraphQLite\Middlewares\SourceInputPropertyResolver; use TheCodingMachine\GraphQLite\Middlewares\SourceMethodResolver; +use TheCodingMachine\GraphQLite\Middlewares\SourcePropertyResolver; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; use TheCodingMachine\GraphQLite\Utils\Cloneable; @@ -28,28 +31,21 @@ class InputFieldDescriptor { use Cloneable; - private readonly ResolverInterface $originalResolver; - /** @var callable */ - private readonly mixed $resolver; - /** * @param array $parameters - * @param callable|null $callable + * @param callable $resolver * @param bool $injectSource Whether we should inject the source as the first parameter or not. */ public function __construct( private readonly string $name, private readonly InputType&Type $type, + private readonly mixed $resolver, + private readonly SourceInputPropertyResolver|SourceConstructorParameterResolver|SourceMethodResolver|ServiceResolver $originalResolver, private readonly array $parameters = [], - private readonly mixed $callable = null, - private readonly string|null $targetClass = null, - private readonly string|null $targetMethodOnSource = null, - private readonly string|null $targetPropertyOnSource = null, private readonly bool $injectSource = false, + private readonly bool $forConstructorHydration = false, private readonly string|null $comment = null, private readonly MiddlewareAnnotations $middlewareAnnotations = new MiddlewareAnnotations([]), - private readonly ReflectionMethod|null $refMethod = null, - private readonly ReflectionProperty|null $refProperty = null, private readonly bool $isUpdate = false, private readonly bool $hasDefaultValue = false, private readonly mixed $defaultValue = null, @@ -119,67 +115,24 @@ public function withParameters(array $parameters): self return $this->with(parameters: $parameters); } - /** - * Sets the callable targeting the resolver function if the resolver function is part of a service. - * This should not be used in the context of a field middleware. - * Use getResolver/setResolver if you want to wrap the resolver in another method. - */ - public function withCallable(callable $callable): self - { - if (isset($this->originalResolver)) { - throw new GraphQLRuntimeException('You cannot modify the target method via withCallable because it was already used. You can still wrap the callable using getResolver/withResolver'); - } - - // To be enabled in a future PR - // $this->magicProperty = null; - return $this->with( - callable: $callable, - targetClass: null, - targetMethodOnSource: null, - targetPropertyOnSource: null, - ); - } - - public function withTargetMethodOnSource(string $className, string $targetMethodOnSource): self + public function isInjectSource(): bool { - if (isset($this->originalResolver)) { - throw new GraphQLRuntimeException('You cannot modify the target method via withTargetMethodOnSource because it was already used. You can still wrap the callable using getResolver/withResolver'); - } - - // To be enabled in a future PR - // $this->magicProperty = null; - return $this->with( - callable: null, - targetClass: $className, - targetMethodOnSource: $targetMethodOnSource, - targetPropertyOnSource: null, - ); + return $this->injectSource; } - public function withTargetPropertyOnSource(string $className, string|null $targetPropertyOnSource): self + public function withInjectSource(bool $injectSource): self { - if (isset($this->originalResolver)) { - throw new GraphQLRuntimeException('You cannot modify the target method via withTargetMethodOnSource because it was already used. You can still wrap the callable using getResolver/withResolver'); - } - - // To be enabled in a future PR - // $this->magicProperty = null; - return $this->with( - callable: null, - targetClass: $className, - targetMethodOnSource: null, - targetPropertyOnSource: $targetPropertyOnSource, - ); + return $this->with(injectSource: $injectSource); } - public function isInjectSource(): bool + public function isForConstructorHydration(): bool { - return $this->injectSource; + return $this->forConstructorHydration; } - public function withInjectSource(bool $injectSource): self + public function withForConstructorHydration(bool $forConstructorHydration): self { - return $this->with(injectSource: $injectSource); + return $this->with(forConstructorHydration: $forConstructorHydration); } public function getComment(): string|null @@ -202,54 +155,11 @@ public function withMiddlewareAnnotations(MiddlewareAnnotations $middlewareAnnot return $this->with(middlewareAnnotations: $middlewareAnnotations); } - public function getRefMethod(): ReflectionMethod|null - { - return $this->refMethod; - } - - public function withRefMethod(ReflectionMethod $refMethod): self - { - return $this->with(refMethod: $refMethod); - } - - public function getRefProperty(): ReflectionProperty|null - { - return $this->refProperty; - } - - public function withRefProperty(ReflectionProperty $refProperty): self - { - return $this->with(refProperty: $refProperty); - } - /** * Returns the original callable that will be used to resolve the field. */ - public function getOriginalResolver(): ResolverInterface + public function getOriginalResolver(): SourceInputPropertyResolver|SourceConstructorParameterResolver|SourceMethodResolver|ServiceResolver { - if (isset($this->originalResolver)) { - return $this->originalResolver; - } - - if (is_callable($this->callable)) { - /** @var callable&array{0:object, 1:string} $callable */ - $callable = $this->callable; - $this->originalResolver = new ServiceResolver($callable); - } elseif ($this->targetMethodOnSource !== null) { - assert($this->targetClass !== null); - - $this->originalResolver = new SourceMethodResolver($this->targetClass, $this->targetMethodOnSource); - } elseif ($this->targetPropertyOnSource !== null) { - assert($this->targetClass !== null); - - $this->originalResolver = new SourceInputPropertyResolver($this->targetClass, $this->targetPropertyOnSource); - // } elseif ($this->magicProperty !== null) { - // Enable magic properties in a future PR - // $this->originalResolver = new MagicInputPropertyResolver($this->magicProperty); - } else { - throw new GraphQLRuntimeException('The InputFieldDescriptor should be passed either a resolve method (via withCallable) or a target method on source object (via withTargetMethodOnSource).'); - } - return $this->originalResolver; } @@ -259,10 +169,6 @@ public function getOriginalResolver(): ResolverInterface */ public function getResolver(): callable { - if (! isset($this->resolver)) { - $this->resolver = $this->getOriginalResolver(); - } - return $this->resolver; } @@ -270,20 +176,4 @@ public function withResolver(callable $resolver): self { return $this->with(resolver: $resolver); } - - /* - * Set the magic property - * - * @todo enable this in a future PR - * - * public function setMagicProperty(string $magicProperty): void - * { - * if ($this->originalResolver !== null) { - * throw new GraphQLRuntimeException('You cannot modify the target method via withMagicProperty because it was already used. You can still wrap the callable using getResolver/withResolver'); - * } - * $this->targetMethodOnSource = null; - * $this->targetPropertyOnSource = null; - * return $this->with(magicProperty: $magicProperty); - * } - */ } diff --git a/src/Middlewares/BadExpressionInSecurityException.php b/src/Middlewares/BadExpressionInSecurityException.php index 1a1fda25bb..31964d0d0c 100644 --- a/src/Middlewares/BadExpressionInSecurityException.php +++ b/src/Middlewares/BadExpressionInSecurityException.php @@ -16,8 +16,8 @@ class BadExpressionInSecurityException extends Exception { public static function wrapException(Throwable $e, QueryFieldDescriptor|InputFieldDescriptor $fieldDescriptor): self { - $refMethod = $fieldDescriptor->getRefMethod(); - $message = 'An error occurred while evaluating expression in @Security annotation of method "' . $refMethod?->getDeclaringClass()?->getName() . '::' . $refMethod?->getName() . '": ' . $e->getMessage(); + $originalResolver = $fieldDescriptor->getOriginalResolver(); + $message = 'An error occurred while evaluating expression in @Security annotation of method "' . $originalResolver->toString() . '": ' . $e->getMessage(); return new self($message, $e->getCode(), $e); } diff --git a/src/Middlewares/MagicPropertyResolver.php b/src/Middlewares/MagicPropertyResolver.php index 43cbe498ce..2338442396 100644 --- a/src/Middlewares/MagicPropertyResolver.php +++ b/src/Middlewares/MagicPropertyResolver.php @@ -15,18 +15,30 @@ */ final class MagicPropertyResolver implements ResolverInterface { + /** + * @param class-string $className + */ public function __construct( private readonly string $className, private readonly string $propertyName, ) { } - public function executionSource(object|null $source): object + /** + * @return class-string + */ + public function className(): string { - if ($source === null) { - throw new GraphQLRuntimeException('You must provide a source for MagicPropertyResolver.'); - } + return $this->className; + } + public function propertyName(): string + { + return $this->propertyName; + } + + public function executionSource(object|null $source): object|null + { return $source; } diff --git a/src/Middlewares/ResolverInterface.php b/src/Middlewares/ResolverInterface.php index 6b025f9e15..5971ad1678 100644 --- a/src/Middlewares/ResolverInterface.php +++ b/src/Middlewares/ResolverInterface.php @@ -18,7 +18,7 @@ public function toString(): string; * the {@see ExtendedContactType::uppercaseName()} field, the source is a {@see Contact} * object, but execution source will be an instance of {@see ExtendedContactType}. */ - public function executionSource(object|null $source): object; + public function executionSource(object|null $source): object|null; public function __invoke(object|null $source, mixed ...$args): mixed; } diff --git a/src/Middlewares/SecurityFieldMiddleware.php b/src/Middlewares/SecurityFieldMiddleware.php index c3182dd962..d9fabaa0c1 100644 --- a/src/Middlewares/SecurityFieldMiddleware.php +++ b/src/Middlewares/SecurityFieldMiddleware.php @@ -104,7 +104,7 @@ public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandler * * @return array */ - private function getVariables(array $args, array $parameters, object $source): array + private function getVariables(array $args, array $parameters, object|null $source): array { $variables = [ // If a user is not logged, we provide an empty user object to make usage easier diff --git a/src/Middlewares/SecurityInputFieldMiddleware.php b/src/Middlewares/SecurityInputFieldMiddleware.php index d930a08f37..9eaba0b626 100644 --- a/src/Middlewares/SecurityInputFieldMiddleware.php +++ b/src/Middlewares/SecurityInputFieldMiddleware.php @@ -73,7 +73,7 @@ public function process(InputFieldDescriptor $inputFieldDescriptor, InputFieldHa * * @return array */ - private function getVariables(array $args, array $parameters, object $source): array + private function getVariables(array $args, array $parameters, object|null $source): array { $variables = [ // If a user is not logged, we provide an empty user object to make usage easier diff --git a/src/Middlewares/ServiceResolver.php b/src/Middlewares/ServiceResolver.php index 873ced5729..fb4027ca3b 100644 --- a/src/Middlewares/ServiceResolver.php +++ b/src/Middlewares/ServiceResolver.php @@ -22,6 +22,12 @@ public function __construct(callable $callable) $this->callable = $callable; } + /** @return callable&array{0:object, 1:string} */ + public function callable(): callable + { + return $this->callable; + } + public function executionSource(object|null $source): object { return $this->callable[0]; diff --git a/src/Middlewares/SourceConstructorParameterResolver.php b/src/Middlewares/SourceConstructorParameterResolver.php new file mode 100644 index 0000000000..4611b423d8 --- /dev/null +++ b/src/Middlewares/SourceConstructorParameterResolver.php @@ -0,0 +1,50 @@ +className; + } + + public function parameterName(): string + { + return $this->parameterName; + } + + public function executionSource(object|null $source): object|null + { + return $source; + } + + public function __invoke(object|null $source, mixed ...$args): mixed + { + return $args[0]; + } + + public function toString(): string + { + return $this->className . '::__construct($' . $this->parameterName . ')'; + } +} \ No newline at end of file diff --git a/src/Middlewares/SourceInputPropertyResolver.php b/src/Middlewares/SourceInputPropertyResolver.php index a8405d52a5..b41232c83c 100644 --- a/src/Middlewares/SourceInputPropertyResolver.php +++ b/src/Middlewares/SourceInputPropertyResolver.php @@ -4,6 +4,7 @@ namespace TheCodingMachine\GraphQLite\Middlewares; +use ReflectionProperty; use TheCodingMachine\GraphQLite\GraphQLRuntimeException; use TheCodingMachine\GraphQLite\Utils\PropertyAccessor; @@ -15,18 +16,18 @@ final class SourceInputPropertyResolver implements ResolverInterface { public function __construct( - private readonly string $className, - private readonly string $propertyName, + private readonly ReflectionProperty $propertyReflection, ) { } - public function executionSource(object|null $source): object + public function propertyReflection(): ReflectionProperty { - if ($source === null) { - throw new GraphQLRuntimeException('You must provide a source for SourceInputPropertyResolver.'); - } + return $this->propertyReflection; + } + public function executionSource(object|null $source): object|null + { return $source; } @@ -36,13 +37,13 @@ public function __invoke(object|null $source, mixed ...$args): mixed throw new GraphQLRuntimeException('You must provide a source for SourceInputPropertyResolver.'); } - PropertyAccessor::setValue($source, $this->propertyName, ...$args); + PropertyAccessor::setValue($source, $this->propertyReflection->getName(), ...$args); return $args[0]; } public function toString(): string { - return $this->className . '::' . $this->propertyName; + return $this->propertyReflection->getDeclaringClass()->getName() . '::' . $this->propertyReflection->getName(); } } diff --git a/src/Middlewares/SourceMethodResolver.php b/src/Middlewares/SourceMethodResolver.php index 2aedcfa61b..6fc862fe7a 100644 --- a/src/Middlewares/SourceMethodResolver.php +++ b/src/Middlewares/SourceMethodResolver.php @@ -17,18 +17,18 @@ final class SourceMethodResolver implements ResolverInterface { public function __construct( - private readonly string $className, - private readonly string $methodName, + private readonly \ReflectionMethod $methodReflection, ) { } - public function executionSource(object|null $source): object + public function methodReflection(): \ReflectionMethod { - if ($source === null) { - throw new GraphQLRuntimeException('You must provide a source for SourceMethodResolver.'); - } + return $this->methodReflection; + } + public function executionSource(object|null $source): object|null + { return $source; } @@ -38,7 +38,7 @@ public function __invoke(object|null $source, mixed ...$args): mixed throw new GraphQLRuntimeException('You must provide a source for SourceMethodResolver.'); } - $callable = [$source, $this->methodName]; + $callable = [$source, $this->methodReflection->getName()]; assert(is_callable($callable)); return $callable(...$args); @@ -46,6 +46,6 @@ public function __invoke(object|null $source, mixed ...$args): mixed public function toString(): string { - return $this->className . '::' . $this->methodName . '()'; + return $this->methodReflection->getDeclaringClass()->getName() . '::' . $this->methodReflection->getName() . '()'; } } diff --git a/src/Middlewares/SourcePropertyResolver.php b/src/Middlewares/SourcePropertyResolver.php index 72fa720aab..3448deacb6 100644 --- a/src/Middlewares/SourcePropertyResolver.php +++ b/src/Middlewares/SourcePropertyResolver.php @@ -4,6 +4,7 @@ namespace TheCodingMachine\GraphQLite\Middlewares; +use ReflectionProperty; use TheCodingMachine\GraphQLite\GraphQLRuntimeException; use TheCodingMachine\GraphQLite\Utils\PropertyAccessor; @@ -15,18 +16,18 @@ final class SourcePropertyResolver implements ResolverInterface { public function __construct( - private readonly string $className, - private readonly string $propertyName, + private readonly ReflectionProperty $propertyReflection, ) { } - public function executionSource(object|null $source): object + public function propertyReflection(): ReflectionProperty { - if ($source === null) { - throw new GraphQLRuntimeException('You must provide a source for SourcePropertyResolver.'); - } + return $this->propertyReflection; + } + public function executionSource(object|null $source): object|null + { return $source; } @@ -36,11 +37,11 @@ public function __invoke(object|null $source, mixed ...$args): mixed throw new GraphQLRuntimeException('You must provide a source for SourcePropertyResolver.'); } - return PropertyAccessor::getValue($source, $this->propertyName, ...$args); + return PropertyAccessor::getValue($source, $this->propertyReflection->getName(), ...$args); } public function toString(): string { - return $this->className . '::' . $this->propertyName; + return $this->propertyReflection->getDeclaringClass()->getName() . '::' . $this->propertyReflection->getName(); } } diff --git a/src/QueryFieldDescriptor.php b/src/QueryFieldDescriptor.php index e12b88a2ee..13bdc491f0 100644 --- a/src/QueryFieldDescriptor.php +++ b/src/QueryFieldDescriptor.php @@ -12,6 +12,7 @@ use TheCodingMachine\GraphQLite\Middlewares\MagicPropertyResolver; use TheCodingMachine\GraphQLite\Middlewares\ResolverInterface; use TheCodingMachine\GraphQLite\Middlewares\ServiceResolver; +use TheCodingMachine\GraphQLite\Middlewares\SourceInputPropertyResolver; use TheCodingMachine\GraphQLite\Middlewares\SourceMethodResolver; use TheCodingMachine\GraphQLite\Middlewares\SourcePropertyResolver; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; @@ -29,30 +30,21 @@ class QueryFieldDescriptor { use Cloneable; - private readonly ResolverInterface $originalResolver; - /** @var callable */ - private readonly mixed $resolver; - /** * @param array $parameters - * @param callable $callable + * @param callable $resolver * @param bool $injectSource Whether we should inject the source as the first parameter or not. */ public function __construct( private readonly string $name, private readonly OutputType&Type $type, + private readonly mixed $resolver, + private readonly SourcePropertyResolver|MagicPropertyResolver|SourceMethodResolver|ServiceResolver $originalResolver, private readonly array $parameters = [], - private readonly mixed $callable = null, - private readonly string|null $targetClass = null, - private readonly string|null $targetMethodOnSource = null, - private readonly string|null $targetPropertyOnSource = null, - private readonly string|null $magicProperty = null, private readonly bool $injectSource = false, private readonly string|null $comment = null, private readonly string|null $deprecationReason = null, private readonly MiddlewareAnnotations $middlewareAnnotations = new MiddlewareAnnotations([]), - private readonly ReflectionMethod|null $refMethod = null, - private readonly ReflectionProperty|null $refProperty = null, ) { } @@ -89,71 +81,6 @@ public function withParameters(array $parameters): self return $this->with(parameters: $parameters); } - /** - * Sets the callable targeting the resolver function if the resolver function is part of a service. - * This should not be used in the context of a field middleware. - * Use getResolver/setResolver if you want to wrap the resolver in another method. - */ - public function withCallable(callable $callable): self - { - if (isset($this->originalResolver)) { - throw new GraphQLRuntimeException('You cannot modify the callable via withCallable because it was already used. You can still wrap the callable using getResolver/withResolver'); - } - - return $this->with( - callable: $callable, - targetClass: null, - targetMethodOnSource: null, - targetPropertyOnSource: null, - magicProperty: null, - ); - } - - public function withTargetMethodOnSource(string $className, string $targetMethodOnSource): self - { - if (isset($this->originalResolver)) { - throw new GraphQLRuntimeException('You cannot modify the target method via withTargetMethodOnSource because it was already used. You can still wrap the callable using getResolver/withResolver'); - } - - return $this->with( - callable: null, - targetClass: $className, - targetMethodOnSource: $targetMethodOnSource, - targetPropertyOnSource: null, - magicProperty: null, - ); - } - - public function withTargetPropertyOnSource(string $className, string|null $targetPropertyOnSource): self - { - if (isset($this->originalResolver)) { - throw new GraphQLRuntimeException('You cannot modify the target method via withTargetMethodOnSource because it was already used. You can still wrap the callable using getResolver/withResolver'); - } - - return $this->with( - callable: null, - targetClass: $className, - targetMethodOnSource: null, - targetPropertyOnSource: $targetPropertyOnSource, - magicProperty: null, - ); - } - - public function withMagicProperty(string $className, string $magicProperty): self - { - if (isset($this->originalResolver)) { - throw new GraphQLRuntimeException('You cannot modify the target method via withMagicProperty because it was already used. You can still wrap the callable using getResolver/withResolver'); - } - - return $this->with( - callable: null, - targetClass: $className, - targetMethodOnSource: null, - targetPropertyOnSource: null, - magicProperty: $magicProperty, - ); - } - public function isInjectSource(): bool { return $this->injectSource; @@ -203,55 +130,11 @@ public function withMiddlewareAnnotations(MiddlewareAnnotations $middlewareAnnot return $this->with(middlewareAnnotations: $middlewareAnnotations); } - public function getRefMethod(): ReflectionMethod|null - { - return $this->refMethod; - } - - public function withRefMethod(ReflectionMethod $refMethod): self - { - return $this->with(refMethod: $refMethod); - } - - public function getRefProperty(): ReflectionProperty|null - { - return $this->refProperty; - } - - public function withRefProperty(ReflectionProperty $refProperty): self - { - return $this->with(refProperty: $refProperty); - } - /** * Returns the original callable that will be used to resolve the field. */ - public function getOriginalResolver(): ResolverInterface + public function getOriginalResolver(): SourcePropertyResolver|MagicPropertyResolver|SourceMethodResolver|ServiceResolver { - if (isset($this->originalResolver)) { - return $this->originalResolver; - } - - if (is_array($this->callable)) { - /** @var callable&array{0:object, 1:string} $callable */ - $callable = $this->callable; - $this->originalResolver = new ServiceResolver($callable); - } elseif ($this->targetMethodOnSource !== null) { - assert($this->targetClass !== null); - - $this->originalResolver = new SourceMethodResolver($this->targetClass, $this->targetMethodOnSource); - } elseif ($this->targetPropertyOnSource !== null) { - assert($this->targetClass !== null); - - $this->originalResolver = new SourcePropertyResolver($this->targetClass, $this->targetPropertyOnSource); - } elseif ($this->magicProperty !== null) { - assert($this->targetClass !== null); - - $this->originalResolver = new MagicPropertyResolver($this->targetClass, $this->magicProperty); - } else { - throw new GraphQLRuntimeException('The QueryFieldDescriptor should be passed either a resolve method (via withCallable) or a target method on source object (via withTargetMethodOnSource) or a magic property (via withMagicProperty).'); - } - return $this->originalResolver; } @@ -261,10 +144,6 @@ public function getOriginalResolver(): ResolverInterface */ public function getResolver(): callable { - if (! isset($this->resolver)) { - $this->resolver = $this->getOriginalResolver(); - } - return $this->resolver; } diff --git a/src/Types/InputType.php b/src/Types/InputType.php index d026255b17..4135a3ffb9 100644 --- a/src/Types/InputType.php +++ b/src/Types/InputType.php @@ -4,6 +4,7 @@ namespace TheCodingMachine\GraphQLite\Types; +use ArgumentCountError; use GraphQL\Type\Definition\ResolveInfo; use ReflectionClass; use TheCodingMachine\GraphQLite\FailedResolvingInputType; @@ -66,19 +67,9 @@ public function __construct( /** @param array $args */ public function resolve(object|null $source, array $args, mixed $context, ResolveInfo $resolveInfo): object { - $constructorArgs = []; - foreach ($this->constructorInputFields as $constructorInputField) { - $name = $constructorInputField->name; - $resolve = $constructorInputField->getResolve(); - - if (! array_key_exists($name, $args)) { - continue; - } - - $constructorArgs[$name] = $resolve(null, $args, $context, $resolveInfo); - } - - $instance = $this->createInstance($constructorArgs); + // Sometimes developers may wish to pull the source from somewhere (like a model from a database) + // instead of actually creating a new instance. So if given, we'll use that. + $source = $this->createInstance($this->makeConstructorArgs($source, $args, $context, $resolveInfo)); foreach ($this->inputFields as $inputField) { $name = $inputField->name; @@ -87,14 +78,14 @@ public function resolve(object|null $source, array $args, mixed $context, Resolv } $resolve = $inputField->getResolve(); - $resolve($instance, $args, $context, $resolveInfo); + $resolve($source, $args, $context, $resolveInfo); } if ($this->inputTypeValidator && $this->inputTypeValidator->isEnabled()) { - $this->inputTypeValidator->validate($instance); + $this->inputTypeValidator->validate($source); } - return $instance; + return $source; } public function decorate(callable $decorator): void @@ -102,6 +93,30 @@ public function decorate(callable $decorator): void throw FailedResolvingInputType::createForDecorator($this->className); } + /** + * @param array $args + * + * @return array + */ + private function makeConstructorArgs(object|null $source, array $args, mixed $context, ResolveInfo $resolveInfo): array + { + $constructorArgs = []; + foreach ($this->constructorInputFields as $constructorInputField) { + $name = $constructorInputField->name; + $resolve = $constructorInputField->getResolve(); + + if (! array_key_exists($name, $args)) { + continue; + } + + // Although $source will most likely be either `null` or unused by the resolver, we'll still + // pass it in there in case the developer does want to use a source somehow. + $constructorArgs[$name] = $resolve($source, $args, $context, $resolveInfo); + } + + return $constructorArgs; + } + /** * Creates an instance of the input class. * @@ -110,23 +125,13 @@ public function decorate(callable $decorator): void private function createInstance(array $values): object { $refClass = new ReflectionClass($this->className); - $constructor = $refClass->getConstructor(); - $constructorParameters = $constructor ? $constructor->getParameters() : []; - - $parameters = []; - foreach ($constructorParameters as $parameter) { - $name = $parameter->getName(); - if (! array_key_exists($name, $values)) { - if (! $parameter->isDefaultValueAvailable()) { - throw FailedResolvingInputType::createForMissingConstructorParameter($refClass->getName(), $name); - } - - $values[$name] = $parameter->getDefaultValue(); - } - $parameters[] = $values[$name]; + try { + // This is the same as named parameters syntax, meaning default values are automatically used + // and any missing properties without default values will throw a fatal error. + return $refClass->newInstance(...$values); + } catch (ArgumentCountError $e) { + throw FailedResolvingInputType::createForMissingConstructorParameter($e); } - - return $refClass->newInstanceArgs($parameters); } } diff --git a/tests/FieldsBuilderTest.php b/tests/FieldsBuilderTest.php index 9f25172526..ae039b1c18 100644 --- a/tests/FieldsBuilderTest.php +++ b/tests/FieldsBuilderTest.php @@ -777,7 +777,7 @@ public function testSecurityBadQuery(): void $resolve = $query->resolveFn; $this->expectException(BadExpressionInSecurityException::class); - $this->expectExceptionMessage('An error occurred while evaluating expression in @Security annotation of method "TheCodingMachine\GraphQLite\Fixtures\TestControllerWithBadSecurity::testBadSecurity": Unexpected token "name" of value "is" around position 6 for expression `this is not valid expression language`.'); + $this->expectExceptionMessage('An error occurred while evaluating expression in @Security annotation of method "TheCodingMachine\GraphQLite\Fixtures\TestControllerWithBadSecurity::testBadSecurity()": Unexpected token "name" of value "is" around position 6 for expression `this is not valid expression language`.'); $result = $resolve(new stdClass(), [], null, $this->createMock(ResolveInfo::class)); } diff --git a/tests/Fixtures/Inputs/TestConstructorAndPropertiesInvalid.php b/tests/Fixtures/Inputs/TestConstructorPromotedProperties.php similarity index 53% rename from tests/Fixtures/Inputs/TestConstructorAndPropertiesInvalid.php rename to tests/Fixtures/Inputs/TestConstructorPromotedProperties.php index 2c55bbe0cb..7eaaae3356 100644 --- a/tests/Fixtures/Inputs/TestConstructorAndPropertiesInvalid.php +++ b/tests/Fixtures/Inputs/TestConstructorPromotedProperties.php @@ -2,39 +2,24 @@ namespace TheCodingMachine\GraphQLite\Fixtures\Inputs; -use Exception; use TheCodingMachine\GraphQLite\Annotations\Field; use TheCodingMachine\GraphQLite\Annotations\Input; use TheCodingMachine\GraphQLite\Annotations\Right; -/** - * @Input() - */ -class TestConstructorAndPropertiesInvalid +#[Input] +class TestConstructorPromotedProperties { - - /** - * @Field() - */ - private \DateTimeImmutable $date; - - /** - * @Field() - * @Right("INVALID_MIDDLEWARE") - * @var string - */ - private $foo; - - /** - * @Field() - * @var int - */ - private $bar; - - public function __construct(\DateTimeImmutable $date, string $foo) + #[Field] + private int $bar; + + public function __construct( + #[Field] + private readonly \DateTimeImmutable $date, + #[Field] + #[Right('FOOOOO')] + public string $foo + ) { - $this->date = $date; - $this->foo = $foo; } public function getDate(): \DateTimeImmutable @@ -47,11 +32,6 @@ public function setFoo(string $foo): void throw new \RuntimeException("This should not be called"); } - public function getFoo(): string - { - return $this->foo; - } - public function setBar(int $bar): void { $this->bar = $bar; @@ -61,4 +41,4 @@ public function getBar(): int { return $this->bar; } -} +} \ No newline at end of file diff --git a/tests/Fixtures/Integration/Controllers/ArticleController.php b/tests/Fixtures/Integration/Controllers/ArticleController.php index ca2c32eff4..723adc9878 100644 --- a/tests/Fixtures/Integration/Controllers/ArticleController.php +++ b/tests/Fixtures/Integration/Controllers/ArticleController.php @@ -7,6 +7,7 @@ use TheCodingMachine\GraphQLite\Annotations\Query; use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Article; use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Contact; +use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\UpdateArticleInput; use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\User; class ArticleController @@ -34,4 +35,13 @@ public function createArticle(Article $article): Article { return $article; } + + #[Mutation] + public function updateArticle(UpdateArticleInput $input): Article + { + $article = new Article('test'); + $article->magazine = $input->magazine; + + return $article; + } } diff --git a/tests/Fixtures/Integration/Models/UpdateArticleInput.php b/tests/Fixtures/Integration/Models/UpdateArticleInput.php new file mode 100644 index 0000000000..ecd4f538f8 --- /dev/null +++ b/tests/Fixtures/Integration/Models/UpdateArticleInput.php @@ -0,0 +1,19 @@ +toArray(DebugFlag::RETHROW_INTERNAL_EXCEPTIONS); } + public function testEndToEndInputConstructor(): void + { + $schema = $this->mainContainer->get(Schema::class); + assert($schema instanceof Schema); + + $queryString = ' + mutation { + updateArticle(input: { + magazine: "Test" + }) { + magazine + } + } + '; + + $result = GraphQL::executeQuery( + $schema, + $queryString, + ); + + $data = $this->getSuccessResult($result); + $this->assertSame('Test', $data['updateArticle']['magazine']); + $queryString = ' + mutation { + updateArticle(input: { + magazine: "NYTimes" + }) { + magazine + } + } + '; + + $result = GraphQL::executeQuery( + $schema, + $queryString, + ); + + $this->assertSame('Access denied.', $result->toArray(DebugFlag::RETHROW_UNSAFE_EXCEPTIONS)['errors'][0]['message']); + } + public function testEndToEndSetterWithSecurity(): void { $container = $this->createContainer([ diff --git a/tests/Middlewares/AuthorizationFieldMiddlewareTest.php b/tests/Middlewares/AuthorizationFieldMiddlewareTest.php index c596c0399e..f756cae352 100644 --- a/tests/Middlewares/AuthorizationFieldMiddlewareTest.php +++ b/tests/Middlewares/AuthorizationFieldMiddlewareTest.php @@ -102,14 +102,15 @@ public function testThrowsForbiddenExceptionWhenNotAuthorized(): void */ private function stubDescriptor(array $annotations): QueryFieldDescriptor { - $descriptor = new QueryFieldDescriptor( + $resolver = fn () => self::fail('Should not be called.'); + + return new QueryFieldDescriptor( name: 'foo', type: Type::string(), + resolver: $resolver, + originalResolver: new ServiceResolver($resolver), middlewareAnnotations: new MiddlewareAnnotations($annotations), ); - $descriptor = $descriptor->withResolver(fn () => self::fail('Should not be called.')); - - return $descriptor; } private function stubFieldHandler(): FieldHandlerInterface diff --git a/tests/Middlewares/AuthorizationInputFieldMiddlewareTest.php b/tests/Middlewares/AuthorizationInputFieldMiddlewareTest.php index db299e99e6..783feab83b 100644 --- a/tests/Middlewares/AuthorizationInputFieldMiddlewareTest.php +++ b/tests/Middlewares/AuthorizationInputFieldMiddlewareTest.php @@ -84,16 +84,15 @@ public function testThrowsForbiddenExceptionWhenNotAuthorized(): void */ private function stubDescriptor(array $annotations): InputFieldDescriptor { - $descriptor = new InputFieldDescriptor( + $resolver = fn () => self::fail('Should not be called.'); + + return new InputFieldDescriptor( name: 'foo', type: Type::string(), - targetClass: stdClass::class, - targetMethodOnSource: 'foo', + resolver: $resolver, + originalResolver: new ServiceResolver($resolver), middlewareAnnotations: new MiddlewareAnnotations($annotations), ); - $descriptor = $descriptor->withResolver(fn () => self::fail('Should not be called.')); - - return $descriptor; } private function stubFieldHandler(): InputFieldHandlerInterface @@ -109,10 +108,11 @@ public function handle(InputFieldDescriptor $inputFieldDescriptor): InputField|n ], originalResolver: $inputFieldDescriptor->getOriginalResolver(), resolver: $inputFieldDescriptor->getResolver(), + forConstructorHydration: false, comment: null, isUpdate: false, hasDefaultValue: false, - defaultValue: null + defaultValue: null, ); } }; diff --git a/tests/Middlewares/CostFieldMiddlewareTest.php b/tests/Middlewares/CostFieldMiddlewareTest.php index c5ddccabc6..c0e5a93631 100644 --- a/tests/Middlewares/CostFieldMiddlewareTest.php +++ b/tests/Middlewares/CostFieldMiddlewareTest.php @@ -129,14 +129,15 @@ public static function addsCostInDescriptionProvider(): iterable */ private function stubDescriptor(array $annotations): QueryFieldDescriptor { - $descriptor = new QueryFieldDescriptor( + $resolver = fn () => self::fail('Should not be called.'); + + return new QueryFieldDescriptor( name: 'foo', type: Type::string(), + resolver: $resolver, + originalResolver: new ServiceResolver($resolver), middlewareAnnotations: new MiddlewareAnnotations($annotations), ); - $descriptor = $descriptor->withResolver(fn () => self::fail('Should not be called.')); - - return $descriptor; } private function stubFieldHandler(FieldDefinition|null $field): FieldHandlerInterface diff --git a/tests/Middlewares/FieldMiddlewarePipeTest.php b/tests/Middlewares/FieldMiddlewarePipeTest.php index 8fd3815920..0f70d8c468 100644 --- a/tests/Middlewares/FieldMiddlewarePipeTest.php +++ b/tests/Middlewares/FieldMiddlewarePipeTest.php @@ -19,9 +19,12 @@ public function handle(QueryFieldDescriptor $fieldDescriptor): ?FieldDefinition } }; + $resolver = fn () => self::fail('Should not be called.'); $descriptor = new QueryFieldDescriptor( name: 'foo', type: Type::string(), + resolver: $resolver, + originalResolver: new ServiceResolver($resolver), ); $middlewarePipe = new FieldMiddlewarePipe(); @@ -38,6 +41,8 @@ public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandler $descriptor = new QueryFieldDescriptor( name: 'bar', type: Type::string(), + resolver: $resolver, + originalResolver: new ServiceResolver($resolver), ); $definition = $middlewarePipe->process($descriptor, $finalHandler); diff --git a/tests/Middlewares/InputFieldMiddlewarePipeTest.php b/tests/Middlewares/InputFieldMiddlewarePipeTest.php index 5454d5ec8d..7dbe879175 100644 --- a/tests/Middlewares/InputFieldMiddlewarePipeTest.php +++ b/tests/Middlewares/InputFieldMiddlewarePipeTest.php @@ -19,13 +19,15 @@ public function handle(InputFieldDescriptor $inputFieldDescriptor): ?InputField } }; + $resolver = static function (){ + return null; + }; $middlewarePipe = new InputFieldMiddlewarePipe(); $inputFieldDescriptor = new InputFieldDescriptor( name: 'foo', type: Type::string(), - callable: static function (){ - return null; - } + resolver: $resolver, + originalResolver: new ServiceResolver($resolver), ); $definition = $middlewarePipe->process($inputFieldDescriptor, $finalHandler); $this->assertSame('foo', $definition->name); diff --git a/tests/Middlewares/SourceResolverTest.php b/tests/Middlewares/SourceResolverTest.php index 09e1768b3d..5c47a7fbec 100644 --- a/tests/Middlewares/SourceResolverTest.php +++ b/tests/Middlewares/SourceResolverTest.php @@ -3,7 +3,9 @@ namespace TheCodingMachine\GraphQLite\Middlewares; use PHPUnit\Framework\TestCase; +use ReflectionMethod; use stdClass; +use TheCodingMachine\GraphQLite\Fixtures\TestType; use TheCodingMachine\GraphQLite\GraphQLRuntimeException; class SourceResolverTest extends TestCase @@ -11,15 +13,15 @@ class SourceResolverTest extends TestCase public function testExceptionInInvoke() { - $sourceResolver = new SourceMethodResolver(stdClass::class, 'test'); + $sourceResolver = new SourceMethodResolver(new ReflectionMethod(TestType::class, 'customField')); $this->expectException(GraphQLRuntimeException::class); $sourceResolver(null); } public function testToString() { - $sourceResolver = new SourceMethodResolver(stdClass::class, 'test'); + $sourceResolver = new SourceMethodResolver(new ReflectionMethod(TestType::class, 'customField')); - $this->assertSame('stdClass::test()', $sourceResolver->toString()); + $this->assertSame('TheCodingMachine\GraphQLite\Fixtures\TestType::customField()', $sourceResolver->toString()); } } diff --git a/tests/QueryFieldDescriptorTest.php b/tests/QueryFieldDescriptorTest.php index fa42c373da..7ad4a3ba9a 100644 --- a/tests/QueryFieldDescriptorTest.php +++ b/tests/QueryFieldDescriptorTest.php @@ -5,79 +5,22 @@ use GraphQL\Type\Definition\Type; use PHPUnit\Framework\TestCase; use stdClass; +use TheCodingMachine\GraphQLite\Middlewares\ServiceResolver; class QueryFieldDescriptorTest extends TestCase { - public function testExceptionInSetCallable(): void - { - $descriptor = new QueryFieldDescriptor( - name: 'test', - type: Type::string(), - callable: [$this, 'testExceptionInSetCallable'], - ); - $descriptor->getResolver(); - - $this->expectException(GraphQLRuntimeException::class); - $descriptor->withCallable([$this, 'testExceptionInSetCallable']); - } - - public function testExceptionInSetTargetMethodOnSource(): void - { - $descriptor = new QueryFieldDescriptor( - name: 'test', - type: Type::string(), - targetClass: stdClass::class, - targetMethodOnSource: 'test' - ); - $descriptor->getResolver(); - - $this->expectException(GraphQLRuntimeException::class); - $descriptor->withTargetMethodOnSource(stdClass::class, 'test'); - } - - public function testExceptionInSetTargetPropertyOnSource(): void - { - $descriptor = new QueryFieldDescriptor( - name: 'test', - type: Type::string(), - targetClass: stdClass::class, - targetPropertyOnSource: 'test', - ); - $descriptor->getResolver(); - - $this->expectException(GraphQLRuntimeException::class); - $descriptor->withTargetPropertyOnSource(stdClass::class, 'test'); - } - - public function testExceptionInSetMagicProperty(): void - { - $descriptor = new QueryFieldDescriptor( - name: 'test', - type: Type::string(), - targetClass: stdClass::class, - magicProperty: 'test' - ); - $descriptor->getResolver(); - - $this->expectException(GraphQLRuntimeException::class); - $descriptor->withMagicProperty(stdClass::class, 'test'); - } - - public function testExceptionInGetOriginalResolver(): void - { - $descriptor = new QueryFieldDescriptor('test', Type::string()); - $this->expectException(GraphQLRuntimeException::class); - $descriptor->getOriginalResolver(); - } - /** * @dataProvider withAddedCommentLineProvider */ public function testWithAddedCommentLine(string $expected, string|null $previous, string $added): void { + $resolver = fn () => null; + $descriptor = (new QueryFieldDescriptor( 'test', Type::string(), + resolver: $resolver, + originalResolver: new ServiceResolver($resolver), comment: $previous, ))->withAddedCommentLines($added); diff --git a/tests/QueryFieldTest.php b/tests/QueryFieldTest.php index 6a14a5be33..38b0f19f3b 100644 --- a/tests/QueryFieldTest.php +++ b/tests/QueryFieldTest.php @@ -8,6 +8,7 @@ use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; use PHPUnit\Framework\TestCase; +use ReflectionMethod; use TheCodingMachine\GraphQLite\Fixtures\TestObject; use TheCodingMachine\GraphQLite\Middlewares\ServiceResolver; use TheCodingMachine\GraphQLite\Middlewares\SourceMethodResolver; @@ -19,7 +20,7 @@ class QueryFieldTest extends TestCase { public function testExceptionsHandling(): void { - $sourceResolver = new SourceMethodResolver(TestObject::class, 'getTest'); + $sourceResolver = new SourceMethodResolver(new ReflectionMethod(TestObject::class, 'getTest')); $queryField = new QueryField('foo', Type::string(), [ new class implements ParameterInterface { public function resolve(?object $source, array $args, mixed $context, ResolveInfo $info): mixed diff --git a/tests/Types/InputTypeTest.php b/tests/Types/InputTypeTest.php index be46fdc24b..58026484e0 100644 --- a/tests/Types/InputTypeTest.php +++ b/tests/Types/InputTypeTest.php @@ -17,7 +17,7 @@ use TheCodingMachine\GraphQLite\Fixtures\Inputs\InputInterface; use TheCodingMachine\GraphQLite\Fixtures\Inputs\InputWithSetter; use TheCodingMachine\GraphQLite\Fixtures\Inputs\TestConstructorAndProperties; -use TheCodingMachine\GraphQLite\Fixtures\Inputs\TestConstructorAndPropertiesInvalid; +use TheCodingMachine\GraphQLite\Fixtures\Inputs\TestConstructorPromotedProperties; use TheCodingMachine\GraphQLite\Fixtures\Inputs\TestOnlyConstruct; use TheCodingMachine\GraphQLite\Fixtures\Inputs\TypedFooBar; @@ -175,22 +175,34 @@ public function testResolvesCorrectlyWithConstructorAndProperties(): void $this->assertEquals(200, $result->getBar()); } - /** - * @group PR-466 - */ - public function testConstructorHydrationFailingWithMiddlewareAnnotations(): void + public function testResolvesCorrectlyWithConstructorPromotedProperties(): void { - $this->expectException(IncompatibleAnnotationsException::class); - $input = new InputType( - TestConstructorAndPropertiesInvalid::class, - 'TestConstructorAndPropertiesInvalidInput', + TestConstructorPromotedProperties::class, + 'TestConstructorPromotedPropertiesInput', null, false, $this->getFieldsBuilder(), ); $input->freeze(); $fields = $input->getFields(); + + $date = "2022-05-02T04:42:30Z"; + + $args = [ + 'date' => $date, + 'foo' => 'Foo', + 'bar' => 200, + ]; + + $resolveInfo = $this->createMock(ResolveInfo::class); + + /** @var TestConstructorPromotedProperties $result */ + $result = $input->resolve(null, $args, [], $resolveInfo); + + $this->assertEquals(new DateTime("2022-05-02T04:42:30Z"), $result->getDate()); + $this->assertEquals('Foo', $result->foo); + $this->assertEquals(200, $result->getBar()); } public function testFailsResolvingFieldWithoutRequiredConstructParam(): void @@ -202,7 +214,7 @@ public function testFailsResolvingFieldWithoutRequiredConstructParam(): void $resolveInfo = $this->createMock(ResolveInfo::class); $this->expectException(FailedResolvingInputType::class); - $this->expectExceptionMessage("Parameter 'foo' is missing for class 'TheCodingMachine\GraphQLite\Fixtures\Inputs\FooBar' constructor. It should be mapped as required field."); + $this->expectExceptionMessage("TheCodingMachine\GraphQLite\Fixtures\Inputs\FooBar::__construct(): Argument #1 (\$foo) not passed. It should be mapped as required field."); $input->resolve(null, $args, [], $resolveInfo); } From e8d570a098814e685f122c50244e65a011daeb5c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Dec 2023 15:50:56 -0500 Subject: [PATCH 043/108] Bump JamesIves/github-pages-deploy-action from 4.4.3 to 4.5.0 (#641) Bumps [JamesIves/github-pages-deploy-action](https://github.com/jamesives/github-pages-deploy-action) from 4.4.3 to 4.5.0. - [Release notes](https://github.com/jamesives/github-pages-deploy-action/releases) - [Commits](https://github.com/jamesives/github-pages-deploy-action/compare/v4.4.3...v4.5.0) --- updated-dependencies: - dependency-name: JamesIves/github-pages-deploy-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/doc_generation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/doc_generation.yml b/.github/workflows/doc_generation.yml index a1e61faa28..4bf4397551 100644 --- a/.github/workflows/doc_generation.yml +++ b/.github/workflows/doc_generation.yml @@ -36,7 +36,7 @@ jobs: - name: "Deploy website" if: "${{ github.event_name == 'push' || github.event_name == 'release' }}" - uses: JamesIves/github-pages-deploy-action@v4.4.3 + uses: JamesIves/github-pages-deploy-action@v4.5.0 with: token: ${{ secrets.GITHUB_TOKEN }} branch: gh-pages # The branch the action should deploy to. From a4dac97f6e7981725ed57f58a07ab2bdae6999dc Mon Sep 17 00:00:00 2001 From: Oleksandr Prypkhan Date: Sun, 10 Dec 2023 05:20:05 +0200 Subject: [PATCH 044/108] Fix __call called before setting/getting the property directly (#642) --- src/Utils/PropertyAccessor.php | 32 +++++-- tests/Fixtures/Types/GetterSetterType.php | 46 +++++++++ .../Fixtures/Types/MagicGetterSetterType.php | 20 ++++ tests/Utils/PropertyAccessorTest.php | 95 +++++++++++++++++++ 4 files changed, 183 insertions(+), 10 deletions(-) create mode 100644 tests/Fixtures/Types/GetterSetterType.php create mode 100644 tests/Fixtures/Types/MagicGetterSetterType.php create mode 100644 tests/Utils/PropertyAccessorTest.php diff --git a/src/Utils/PropertyAccessor.php b/src/Utils/PropertyAccessor.php index 2ff46fc4a7..1347d1151c 100644 --- a/src/Utils/PropertyAccessor.php +++ b/src/Utils/PropertyAccessor.php @@ -22,19 +22,14 @@ class PropertyAccessor */ public static function findGetter(string $class, string $propertyName): string|null { - $name = ucfirst($propertyName); - foreach (['get', 'is'] as $prefix) { - $methodName = $prefix . $name; + $methodName = self::propertyToMethodName($prefix, $propertyName); + if (self::isPublicMethod($class, $methodName)) { return $methodName; } } - if (method_exists($class, '__call')) { - return 'get' . $name; - } - return null; } @@ -43,10 +38,9 @@ public static function findGetter(string $class, string $propertyName): string|n */ public static function findSetter(string $class, string $propertyName): string|null { - $name = ucfirst($propertyName); + $methodName = self::propertyToMethodName('set', $propertyName); - $methodName = 'set' . $name; - if (self::isPublicMethod($class, $methodName) || method_exists($class, '__call')) { + if (self::isPublicMethod($class, $methodName)) { return $methodName; } @@ -66,6 +60,12 @@ public static function getValue(object $object, string $propertyName, mixed ...$ return $object->$propertyName; } + if (method_exists($class, '__call')) { + $method = self::propertyToMethodName('get', $propertyName); + + return $object->$method(...$args); + } + throw AccessPropertyException::createForUnreadableProperty($class, $propertyName); } @@ -84,6 +84,13 @@ public static function setValue(object $instance, string $propertyName, mixed $v return; } + if (method_exists($class, '__call')) { + $method = self::propertyToMethodName('set', $propertyName); + + $instance->$method($value); + return; + } + throw AccessPropertyException::createForUnwritableProperty($class, $propertyName); } @@ -117,4 +124,9 @@ private static function isValidGetter(string $class, string $methodName): bool return true; } + + private static function propertyToMethodName(string $prefix, string $propertyName): string + { + return $prefix . ucfirst($propertyName); + } } diff --git a/tests/Fixtures/Types/GetterSetterType.php b/tests/Fixtures/Types/GetterSetterType.php new file mode 100644 index 0000000000..cb11a7e3cf --- /dev/null +++ b/tests/Fixtures/Types/GetterSetterType.php @@ -0,0 +1,46 @@ +two = $value . ' set'; + } + + public function isThree(string $arg = ''): bool + { + return $arg === 'foo'; + } + + private function getFour(string $arg = ''): string + { + throw new \RuntimeException('Should not be called'); + } + + private function setFour(string $value, string $arg): void + { + throw new \RuntimeException('Should not be called'); + } +} \ No newline at end of file diff --git a/tests/Fixtures/Types/MagicGetterSetterType.php b/tests/Fixtures/Types/MagicGetterSetterType.php new file mode 100644 index 0000000000..ea38a52e5a --- /dev/null +++ b/tests/Fixtures/Types/MagicGetterSetterType.php @@ -0,0 +1,20 @@ +magic; + } + + public function __call(string $name, array $arguments) + { + $this->magic = 'magic'; + + return 'magic'; + } +} \ No newline at end of file diff --git a/tests/Utils/PropertyAccessorTest.php b/tests/Utils/PropertyAccessorTest.php new file mode 100644 index 0000000000..c6bc9ecb99 --- /dev/null +++ b/tests/Utils/PropertyAccessorTest.php @@ -0,0 +1,95 @@ + [null, MagicGetterSetterType::class, 'one']; + yield 'getter' => ['getTwo', MagicGetterSetterType::class, 'two']; + yield 'isser' => ['isThree', MagicGetterSetterType::class, 'three']; + yield 'private getter' => [null, MagicGetterSetterType::class, 'four']; + yield 'undefined property' => [null, MagicGetterSetterType::class, 'twenty']; + } + + /** + * @dataProvider findSetterProvider + */ + public function testFindSetter(mixed $expected, string $class, string $propertyName): void + { + self::assertSame($expected, PropertyAccessor::findSetter($class, $propertyName)); + } + + public static function findSetterProvider(): iterable + { + yield 'regular property' => [null, MagicGetterSetterType::class, 'one']; + yield 'setter' => ['setTwo', MagicGetterSetterType::class, 'two']; + yield 'private setter' => [null, MagicGetterSetterType::class, 'four']; + yield 'undefined property' => [null, MagicGetterSetterType::class, 'twenty']; + } + + /** + * @dataProvider getValueProvider + */ + public function testGetValue(mixed $expected, object $object, string $propertyName, array $args = []): void + { + if ($expected instanceof Exception) { + $this->expectExceptionObject($expected); + } + + self::assertSame($expected, PropertyAccessor::getValue($object, $propertyName, ...$args)); + } + + public static function getValueProvider(): iterable + { + yield 'regular property' => ['result', new MagicGetterSetterType(one: 'result'), 'one']; + yield 'getter' => ['result', new MagicGetterSetterType(), 'two', ['result']]; + yield 'isser #1' => [true, new MagicGetterSetterType(), 'three', ['foo']]; + yield 'isser #2' => [false, new MagicGetterSetterType(), 'three', ['bar']]; + yield 'private getter' => ['result', new MagicGetterSetterType(four: 'result'), 'four']; + yield 'magic getter' => ['magic', new MagicGetterSetterType(), 'twenty']; + yield 'undefined property' => [AccessPropertyException::createForUnreadableProperty(GetterSetterType::class, 'twenty'), new GetterSetterType(), 'twenty']; + } + + /** + * @dataProvider setValueProvider + */ + public function testSetValue(mixed $expected, object $object, string $propertyName, mixed $value): void + { + if ($expected instanceof Exception) { + $this->expectExceptionObject($expected); + } + + PropertyAccessor::setValue($object, $propertyName, $value); + + self::assertSame($expected, $object->{$propertyName}); + } + + public static function setValueProvider(): iterable + { + yield 'regular property' => ['result', new MagicGetterSetterType(one: 'result'), 'one', 'result']; + yield 'setter' => ['result set', new MagicGetterSetterType(), 'two', 'result']; + yield 'private setter' => ['result', new MagicGetterSetterType(four: 'result'), 'four', 'result']; + yield 'magic setter' => ['magic', new MagicGetterSetterType(), 'twenty', 'result']; + yield 'undefined property' => [AccessPropertyException::createForUnwritableProperty(GetterSetterType::class, 'twenty'), new GetterSetterType(), 'twenty', 'result']; + } +} \ No newline at end of file From f810e1290f5cc210c4ecc9cf57a2f47ecc6b884a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jan 2024 15:58:11 -0500 Subject: [PATCH 045/108] Bump actions/cache from 3 to 4 (#648) Bumps [actions/cache](https://github.com/actions/cache) from 3 to 4. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/cache dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/continuous_integration.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 2a77ee5f5b..1815c14437 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -44,7 +44,7 @@ jobs: echo "::set-output name=dir::$(composer config cache-files-dir)" - name: composer-cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ${{ steps.composercache.outputs.dir }} key: composer-${{ hashFiles('**/composer.json') }}-${{ matrix.install-args }} @@ -61,7 +61,7 @@ jobs: run: "vendor/bin/phpunit" - name: phpstan-cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: key: phpstan-${{ matrix.php-version }}-${{ matrix.install-args }}-${{ github.ref }}-${{ github.sha }} path: .phpstan-cache From c15213b6aa490286c6b5180c69876e0055aaa87f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jan 2024 15:45:27 -0500 Subject: [PATCH 046/108] Bump styfle/cancel-workflow-action from 0.12.0 to 0.12.1 (#651) Bumps [styfle/cancel-workflow-action](https://github.com/styfle/cancel-workflow-action) from 0.12.0 to 0.12.1. - [Release notes](https://github.com/styfle/cancel-workflow-action/releases) - [Commits](https://github.com/styfle/cancel-workflow-action/compare/0.12.0...0.12.1) --- updated-dependencies: - dependency-name: styfle/cancel-workflow-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 1815c14437..0e9c111975 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -24,7 +24,7 @@ jobs: steps: # Cancel previous runs of the same branch - name: cancel - uses: styfle/cancel-workflow-action@0.12.0 + uses: styfle/cancel-workflow-action@0.12.1 with: access_token: ${{ github.token }} From 0e90ebfa69f4010f01ea39360117cf6845c2db4a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jan 2024 15:45:41 -0500 Subject: [PATCH 047/108] Bump codecov/codecov-action from 3.1.4 to 3.1.5 (#650) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3.1.4 to 3.1.5. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v3.1.4...v3.1.5) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 0e9c111975..65dc772998 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -84,7 +84,7 @@ jobs: name: "codeCoverage" path: "build" - - uses: codecov/codecov-action@v3.1.4 # upload the coverage to codecov + - uses: codecov/codecov-action@v3.1.5 # upload the coverage to codecov with: fail_ci_if_error: true # optional (default = false) # Do not upload in forks, and only on php8, latest deps From b63eaec119e6bf01146e5ca74795b79d98fbc97e Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Sun, 4 Feb 2024 21:38:06 -0500 Subject: [PATCH 048/108] Support subscription operations (#649) * Added support for Subscription annotated controller methods as GraphQL fields * Added tests * Added docs --- src/AggregateControllerQueryProvider.php | 22 ++++- ...ggregateControllerQueryProviderFactory.php | 13 ++- src/AggregateQueryProvider.php | 17 +++- src/Annotations/Subscription.php | 19 ++++ src/FieldsBuilder.php | 55 ++++++----- src/GlobControllerQueryProvider.php | 40 +++++--- src/QueryProviderInterface.php | 3 + src/Schema.php | 45 +++++++-- .../AggregateControllerQueryProviderTest.php | 9 +- tests/AggregateQueryProviderTest.php | 21 ++++- tests/FieldsBuilderTest.php | 31 ++++++- .../Controllers/ContactController.php | 38 ++++---- .../Integration/Types/ContactFactory.php | 12 ++- tests/Fixtures/TestController.php | 92 +++++++++---------- tests/GlobControllerQueryProviderTest.php | 14 ++- tests/Integration/EndToEndTest.php | 59 ++++++++++++ tests/SchemaTest.php | 16 +++- website/docs/README.mdx | 3 +- website/docs/annotations-reference.md | 31 +++++-- website/docs/authentication-authorization.mdx | 25 +++-- website/docs/custom-types.mdx | 1 + website/docs/subscriptions.mdx | 53 +++++++++++ website/docs/validation.mdx | 2 +- website/sidebars.json | 1 + 24 files changed, 459 insertions(+), 163 deletions(-) create mode 100644 src/Annotations/Subscription.php create mode 100644 website/docs/subscriptions.mdx diff --git a/src/AggregateControllerQueryProvider.php b/src/AggregateControllerQueryProvider.php index a85e97bc1f..432e8623a7 100644 --- a/src/AggregateControllerQueryProvider.php +++ b/src/AggregateControllerQueryProvider.php @@ -29,8 +29,11 @@ class AggregateControllerQueryProvider implements QueryProviderInterface * @param iterable $controllers A list of controllers name in the container. * @param ContainerInterface $controllersContainer The container we will fetch controllers from. */ - public function __construct(private readonly iterable $controllers, private readonly FieldsBuilder $fieldsBuilder, private readonly ContainerInterface $controllersContainer) - { + public function __construct( + private readonly iterable $controllers, + private readonly FieldsBuilder $fieldsBuilder, + private readonly ContainerInterface $controllersContainer, + ) { } /** @return array */ @@ -52,13 +55,26 @@ public function getMutations(): array $mutationList = []; foreach ($this->controllers as $controllerName) { - $controller = $this->controllersContainer->get($controllerName); + $controller = $this->controllersContainer->get($controllerName); $mutationList[$controllerName] = $this->fieldsBuilder->getMutations($controller); } return $this->flattenList($mutationList); } + /** @return array */ + public function getSubscriptions(): array + { + $subscriptionList = []; + + foreach ($this->controllers as $controllerName) { + $controller = $this->controllersContainer->get($controllerName); + $subscriptionList[$controllerName] = $this->fieldsBuilder->getSubscriptions($controller); + } + + return $this->flattenList($subscriptionList); + } + /** * @param array> $list * diff --git a/src/AggregateControllerQueryProviderFactory.php b/src/AggregateControllerQueryProviderFactory.php index 1eb77795e8..9a14dcd062 100644 --- a/src/AggregateControllerQueryProviderFactory.php +++ b/src/AggregateControllerQueryProviderFactory.php @@ -15,12 +15,17 @@ class AggregateControllerQueryProviderFactory implements QueryProviderFactoryInt * @param iterable $controllers A list of controllers name in the container. * @param ContainerInterface $controllersContainer The container we will fetch controllers from. */ - public function __construct(private readonly iterable $controllers, private readonly ContainerInterface $controllersContainer) - { - } + public function __construct( + private readonly iterable $controllers, + private readonly ContainerInterface $controllersContainer, + ) {} public function create(FactoryContext $context): QueryProviderInterface { - return new AggregateControllerQueryProvider($this->controllers, $context->getFieldsBuilder(), $this->controllersContainer); + return new AggregateControllerQueryProvider( + $this->controllers, + $context->getFieldsBuilder(), + $this->controllersContainer, + ); } } diff --git a/src/AggregateQueryProvider.php b/src/AggregateQueryProvider.php index fe4d51fd94..3e5110ec97 100644 --- a/src/AggregateQueryProvider.php +++ b/src/AggregateQueryProvider.php @@ -20,7 +20,9 @@ class AggregateQueryProvider implements QueryProviderInterface /** @param QueryProviderInterface[] $queryProviders */ public function __construct(iterable $queryProviders) { - $this->queryProviders = is_array($queryProviders) ? $queryProviders : iterator_to_array($queryProviders); + $this->queryProviders = is_array($queryProviders) + ? $queryProviders + : iterator_to_array($queryProviders); } /** @return QueryField[] */ @@ -48,4 +50,17 @@ public function getMutations(): array return array_merge(...$mutationsArray); } + + /** @return QueryField[] */ + public function getSubscriptions(): array + { + $subscriptionsArray = array_map(static function (QueryProviderInterface $queryProvider) { + return $queryProvider->getSubscriptions(); + }, $this->queryProviders); + if ($subscriptionsArray === []) { + return []; + } + + return array_merge(...$subscriptionsArray); + } } diff --git a/src/Annotations/Subscription.php b/src/Annotations/Subscription.php new file mode 100644 index 0000000000..1d5fce3b15 --- /dev/null +++ b/src/Annotations/Subscription.php @@ -0,0 +1,19 @@ +getFieldsByAnnotations($controller, Mutation::class, false); } + /** + * @return array + * + * @throws ReflectionException + */ + public function getSubscriptions(object $controller): array + { + return $this->getFieldsByAnnotations($controller, Subscription::class, false); + } + /** @return array QueryField indexed by name. */ public function getFields(object $controller, string|null $typeName = null): array { @@ -266,7 +277,7 @@ public function getParametersForDecorator(ReflectionMethod $refMethod): array /** * @param object|class-string $controller The controller instance, or the name of the source class name * @param class-string $annotationName - * @param bool $injectSource Whether to inject the source object or not as the first argument. True for @Field (unless @Type has no class attribute), false for @Query and @Mutation + * @param bool $injectSource Whether to inject the source object or not as the first argument. True for @Field (unless @Type has no class attribute), false for @Query, @Mutation, and @Subscription. * @param string|null $typeName Type name for which fields should be extracted for. * * @return array diff --git a/src/GlobControllerQueryProvider.php b/src/GlobControllerQueryProvider.php index 05afa6476a..208a7b5bfb 100644 --- a/src/GlobControllerQueryProvider.php +++ b/src/GlobControllerQueryProvider.php @@ -4,6 +4,10 @@ namespace TheCodingMachine\GraphQLite; +use function class_exists; +use function interface_exists; +use function is_array; +use function str_replace; use GraphQL\Type\Definition\FieldDefinition; use InvalidArgumentException; use Mouf\Composer\ClassNameMapper; @@ -16,15 +20,11 @@ use TheCodingMachine\ClassExplorer\Glob\GlobClassExplorer; use TheCodingMachine\GraphQLite\Annotations\Mutation; use TheCodingMachine\GraphQLite\Annotations\Query; - -use function class_exists; -use function interface_exists; -use function is_array; -use function str_replace; +use TheCodingMachine\GraphQLite\Annotations\Subscription; /** * Scans all the classes in a given namespace of the main project (not the vendor directory). - * Analyzes all classes and detects "Query" and "Mutation" annotations. + * Analyzes all classes and detects "Query", "Mutation", and "Subscription" annotations. * * Assumes that the container contains a class whose identifier is the same as the class name. */ @@ -53,14 +53,20 @@ public function __construct( ) { $this->classNameMapper = $classNameMapper ?? ClassNameMapper::createFromComposerFile(null, null, true); - $this->cacheContract = new Psr16Adapter($this->cache, str_replace(['\\', '{', '}', '(', ')', '/', '@', ':'], '_', $namespace), $cacheTtl ?? 0); + $this->cacheContract = new Psr16Adapter( + $this->cache, + str_replace(['\\', '{', '}', '(', ')', '/', '@', ':'], '_', $namespace), + $cacheTtl ?? 0, + ); } private function getAggregateControllerQueryProvider(): AggregateControllerQueryProvider { - if ($this->aggregateControllerQueryProvider === null) { - $this->aggregateControllerQueryProvider = new AggregateControllerQueryProvider($this->getInstancesList(), $this->fieldsBuilder, $this->container); - } + $this->aggregateControllerQueryProvider ??= new AggregateControllerQueryProvider( + $this->getInstancesList(), + $this->fieldsBuilder, + $this->container, + ); return $this->aggregateControllerQueryProvider; } @@ -100,7 +106,7 @@ private function buildInstancesList(): array if (! $refClass->isInstantiable()) { continue; } - if (! $this->hasQueriesOrMutations($refClass)) { + if (! $this->hasOperations($refClass)) { continue; } if (! $this->container->has($className)) { @@ -114,7 +120,7 @@ private function buildInstancesList(): array } /** @param ReflectionClass $reflectionClass */ - private function hasQueriesOrMutations(ReflectionClass $reflectionClass): bool + private function hasOperations(ReflectionClass $reflectionClass): bool { foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $refMethod) { $queryAnnotation = $this->annotationReader->getRequestAnnotation($refMethod, Query::class); @@ -125,6 +131,10 @@ private function hasQueriesOrMutations(ReflectionClass $reflectionClass): bool if ($mutationAnnotation !== null) { return true; } + $subscriptionAnnotation = $this->annotationReader->getRequestAnnotation($refMethod, Subscription::class); + if ($subscriptionAnnotation !== null) { + return true; + } } return false; } @@ -140,4 +150,10 @@ public function getMutations(): array { return $this->getAggregateControllerQueryProvider()->getMutations(); } + + /** @return array */ + public function getSubscriptions(): array + { + return $this->getAggregateControllerQueryProvider()->getSubscriptions(); + } } diff --git a/src/QueryProviderInterface.php b/src/QueryProviderInterface.php index f427ec1a67..abb3c783e3 100644 --- a/src/QueryProviderInterface.php +++ b/src/QueryProviderInterface.php @@ -14,4 +14,7 @@ public function getQueries(): array; /** @return QueryField[] */ public function getMutations(): array; + + /** @return QueryField[] */ + public function getSubscriptions(): array; } diff --git a/src/Schema.php b/src/Schema.php index 88f65e7c14..2e53d3c1c7 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -20,13 +20,18 @@ */ class Schema extends \GraphQL\Type\Schema { - public function __construct(QueryProviderInterface $queryProvider, RecursiveTypeMapperInterface $recursiveTypeMapper, TypeResolver $typeResolver, RootTypeMapperInterface $rootTypeMapper, SchemaConfig|null $config = null) - { + public function __construct( + QueryProviderInterface $queryProvider, + RecursiveTypeMapperInterface $recursiveTypeMapper, + TypeResolver $typeResolver, + RootTypeMapperInterface $rootTypeMapper, + SchemaConfig|null $config = null, + ) { if ($config === null) { $config = SchemaConfig::create(); } - $query = new ObjectType([ + $query = new ObjectType([ 'name' => 'Query', 'fields' => static function () use ($queryProvider) { $queries = $queryProvider->getQueries(); @@ -36,7 +41,7 @@ public function __construct(QueryProviderInterface $queryProvider, RecursiveType 'type' => Type::string(), 'description' => 'A placeholder query used by thecodingmachine/graphqlite when there are no declared queries.', 'resolve' => static function () { - return 'This is a placeholder query. Please create a query using the @Query annotation.'; + return 'This is a placeholder query. Please create a query using the "Query" attribute.'; }, ], ]; @@ -45,6 +50,7 @@ public function __construct(QueryProviderInterface $queryProvider, RecursiveType return $queries; }, ]); + $mutation = new ObjectType([ 'name' => 'Mutation', 'fields' => static function () use ($queryProvider) { @@ -55,7 +61,7 @@ public function __construct(QueryProviderInterface $queryProvider, RecursiveType 'type' => Type::string(), 'description' => 'A placeholder query used by thecodingmachine/graphqlite when there are no declared mutations.', 'resolve' => static function () { - return 'This is a placeholder mutation. Please create a mutation using the @Mutation annotation.'; + return 'This is a placeholder mutation. Please create a mutation using the "Mutation" attribute.'; }, ], ]; @@ -65,14 +71,35 @@ public function __construct(QueryProviderInterface $queryProvider, RecursiveType }, ]); + $subscription = new ObjectType([ + 'name' => 'Subscription', + 'fields' => static function () use ($queryProvider) { + $subscriptions = $queryProvider->getSubscriptions(); + if (empty($subscriptions)) { + return [ + 'dummySubscription' => [ + 'type' => Type::string(), + 'description' => 'A placeholder query used by thecodingmachine/graphqlite when there are no declared subscriptions.', + 'resolve' => static function () { + return 'This is a placeholder subscription. Please create a subscription using the "Subscription" attribute.'; + }, + ], + ]; + } + + return $subscriptions; + }, + ]); + $config->setQuery($query); $config->setMutation($mutation); + $config->setSubscription($subscription); $config->setTypes(static function () use ($recursiveTypeMapper) { return $recursiveTypeMapper->getOutputTypes(); }); - $config->setTypeLoader(static function (string $name) use ($query, $mutation, $rootTypeMapper) { + $config->setTypeLoader(static function (string $name) use ($query, $mutation, $subscription, $rootTypeMapper) { // We need to find a type FROM a GraphQL type name if ($name === 'Query') { return $query; @@ -81,7 +108,11 @@ public function __construct(QueryProviderInterface $queryProvider, RecursiveType return $mutation; } - $type = $rootTypeMapper->mapNameToType($name); + if ($name === 'Subscription') { + return $subscription; + } + + $type = $rootTypeMapper->mapNameToType($name); assert($type instanceof Type); return $type; }); diff --git a/tests/AggregateControllerQueryProviderTest.php b/tests/AggregateControllerQueryProviderTest.php index cda3e78aa8..edabec397b 100644 --- a/tests/AggregateControllerQueryProviderTest.php +++ b/tests/AggregateControllerQueryProviderTest.php @@ -2,14 +2,8 @@ namespace TheCodingMachine\GraphQLite; -use Doctrine\Common\Annotations\AnnotationReader; -use PHPUnit\Framework\TestCase; -use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; -use Psr\Container\NotFoundExceptionInterface; use TheCodingMachine\GraphQLite\Fixtures\TestController; -use TheCodingMachine\GraphQLite\Security\VoidAuthenticationService; -use TheCodingMachine\GraphQLite\Security\VoidAuthorizationService; class AggregateControllerQueryProviderTest extends AbstractQueryProviderTest { @@ -41,5 +35,8 @@ public function has($id):bool $mutations = $aggregateQueryProvider->getMutations(); $this->assertCount(2, $mutations); + + $subscriptions = $aggregateQueryProvider->getSubscriptions(); + $this->assertCount(2, $subscriptions); } } diff --git a/tests/AggregateQueryProviderTest.php b/tests/AggregateQueryProviderTest.php index bf63a7c215..cd140a0672 100644 --- a/tests/AggregateQueryProviderTest.php +++ b/tests/AggregateQueryProviderTest.php @@ -21,9 +21,24 @@ public function getMutations(): array $queryFieldRef = new ReflectionClass(QueryField::class); return [ $queryFieldRef->newInstanceWithoutConstructor() ]; } + + public function getSubscriptions(): array + { + $queryFieldRef = new ReflectionClass(QueryField::class); + return [ $queryFieldRef->newInstanceWithoutConstructor() ]; + } }; } + public function testGetQueries(): void + { + $aggregateQueryProvider = new AggregateQueryProvider([$this->getMockQueryProvider(), $this->getMockQueryProvider()]); + $this->assertCount(2, $aggregateQueryProvider->getQueries()); + + $aggregateQueryProvider = new AggregateQueryProvider([]); + $this->assertCount(0, $aggregateQueryProvider->getQueries()); + } + public function testGetMutations(): void { $aggregateQueryProvider = new AggregateQueryProvider([$this->getMockQueryProvider(), $this->getMockQueryProvider()]); @@ -33,12 +48,12 @@ public function testGetMutations(): void $this->assertCount(0, $aggregateQueryProvider->getMutations()); } - public function testGetQueries(): void + public function testGetSubscriptions(): void { $aggregateQueryProvider = new AggregateQueryProvider([$this->getMockQueryProvider(), $this->getMockQueryProvider()]); - $this->assertCount(2, $aggregateQueryProvider->getQueries()); + $this->assertCount(2, $aggregateQueryProvider->getSubscriptions()); $aggregateQueryProvider = new AggregateQueryProvider([]); - $this->assertCount(0, $aggregateQueryProvider->getQueries()); + $this->assertCount(0, $aggregateQueryProvider->getSubscriptions()); } } diff --git a/tests/FieldsBuilderTest.php b/tests/FieldsBuilderTest.php index ae039b1c18..0b3bcf2c00 100644 --- a/tests/FieldsBuilderTest.php +++ b/tests/FieldsBuilderTest.php @@ -14,7 +14,6 @@ use GraphQL\Type\Definition\StringType; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\UnionType; -use PhpParser\Builder\Property; use ReflectionMethod; use stdClass; use Symfony\Component\Cache\Adapter\ArrayAdapter; @@ -142,11 +141,16 @@ public function testMutations(): void $this->assertCount(2, $mutations); - $mutation = $mutations['mutation']; - $this->assertSame('mutation', $mutation->name); + $testReturnMutation = $mutations['testReturn']; + $this->assertSame('testReturn', $testReturnMutation->name); - $resolve = $mutation->resolveFn; - $result = $resolve(new stdClass(), ['testObject' => ['test' => 42]], null, $this->createMock(ResolveInfo::class)); + $resolve = $testReturnMutation->resolveFn; + $result = $resolve( + new stdClass(), + ['testObject' => ['test' => 42]], + null, + $this->createMock(ResolveInfo::class), + ); $this->assertInstanceOf(TestObject::class, $result); $this->assertEquals('42', $result->getTest()); @@ -155,6 +159,23 @@ public function testMutations(): void $this->assertInstanceOf(VoidType::class, $testVoidMutation->getType()); } + public function testSubscriptions(): void + { + $controller = new TestController(); + + $queryProvider = $this->buildFieldsBuilder(); + + $subscriptions = $queryProvider->getSubscriptions($controller); + + $this->assertCount(2, $subscriptions); + + $testSubscribeSubscription = $subscriptions['testSubscribe']; + $this->assertSame('testSubscribe', $testSubscribeSubscription->name); + + $testSubscribeWithInputSubscription = $subscriptions['testSubscribeWithInput']; + $this->assertInstanceOf(IDType::class, $testSubscribeWithInputSubscription->getType()); + } + public function testErrors(): void { $controller = new class { diff --git a/tests/Fixtures/Integration/Controllers/ContactController.php b/tests/Fixtures/Integration/Controllers/ContactController.php index 58a26d88b2..9fe871b7d6 100644 --- a/tests/Fixtures/Integration/Controllers/ContactController.php +++ b/tests/Fixtures/Integration/Controllers/ContactController.php @@ -8,16 +8,16 @@ use Porpaginas\Result; use TheCodingMachine\GraphQLite\Annotations\Mutation; use TheCodingMachine\GraphQLite\Annotations\Query; -use TheCodingMachine\GraphQLite\Annotations\Security; +use TheCodingMachine\GraphQLite\Annotations\Subscription; use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Contact; use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\User; class ContactController { /** - * @Query() * @return Contact[] */ + #[Query] public function getContacts(): array { return [ @@ -26,21 +26,13 @@ public function getContacts(): array ]; } - /** - * @Mutation() - * @param Contact $contact - * @return Contact - */ + #[Mutation] public function saveContact(Contact $contact): Contact { return $contact; } - /** - * @Mutation() - * @param \DateTimeInterface $birthDate - * @return Contact - */ + #[Mutation] public function saveBirthDate(\DateTimeInterface $birthDate): Contact { $contact = new Contact('Bill'); $contact->setBirthDate($birthDate); @@ -49,9 +41,9 @@ public function saveBirthDate(\DateTimeInterface $birthDate): Contact { } /** - * @Query() * @return Contact[] */ + #[Query] public function getContactsIterator(): ArrayResult { return new ArrayResult([ @@ -61,9 +53,9 @@ public function getContactsIterator(): ArrayResult } /** - * @Query() * @return string[]|ArrayResult */ + #[Query] public function getContactsNamesIterator(): ArrayResult { return new ArrayResult([ @@ -72,9 +64,7 @@ public function getContactsNamesIterator(): ArrayResult ]); } - /** - * @Query(outputType="ContactOther") - */ + #[Query(outputType: 'ContactOther')] public function getOtherContact(): Contact { return new Contact('Joe'); @@ -83,11 +73,23 @@ public function getOtherContact(): Contact /** * Test that we can have nullable results from Porpaginas. * - * @Query() * @return Result|Contact[]|null */ + #[Query] public function getNullableResult(): ?Result { return null; } + + #[Subscription] + public function contactAdded(): Contact + { + return new Contact('Joe'); + } + + #[Subscription(outputType: 'Contact')] + public function contactAddedWithFilter(Contact $contact): void + { + // Save the subscription somewhere + } } diff --git a/tests/Fixtures/Integration/Types/ContactFactory.php b/tests/Fixtures/Integration/Types/ContactFactory.php index 5f81fc602a..782394bdd4 100644 --- a/tests/Fixtures/Integration/Types/ContactFactory.php +++ b/tests/Fixtures/Integration/Types/ContactFactory.php @@ -7,7 +7,6 @@ use DateTimeInterface; use Psr\Http\Message\UploadedFileInterface; use TheCodingMachine\GraphQLite\Annotations\Factory; -use TheCodingMachine\GraphQLite\Annotations\Parameter; use TheCodingMachine\GraphQLite\Annotations\UseInputType; use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Contact; @@ -16,12 +15,15 @@ class ContactFactory /** * @Factory() * @UseInputType(for="$relations", inputType="[ContactRef!]!") - * @param string $name - * @param Contact|null $manager * @param Contact[] $relations - * @return Contact */ - public function createContact(string $name, DateTimeInterface $birthDate, ?UploadedFileInterface $photo = null, ?Contact $manager = null, array $relations= []): Contact + public function createContact( + string $name, + DateTimeInterface $birthDate, + ?UploadedFileInterface $photo = null, + ?Contact $manager = null, + array $relations= [], + ): Contact { $contact = new Contact($name); if ($photo) { diff --git a/tests/Fixtures/TestController.php b/tests/Fixtures/TestController.php index ab69dbc14b..c827451625 100644 --- a/tests/Fixtures/TestController.php +++ b/tests/Fixtures/TestController.php @@ -9,35 +9,27 @@ use TheCodingMachine\GraphQLite\Annotations\Mutation; use TheCodingMachine\GraphQLite\Annotations\Query; use TheCodingMachine\GraphQLite\Annotations\Right; +use TheCodingMachine\GraphQLite\Annotations\Subscription; use TheCodingMachine\GraphQLite\Types\ID; class TestController { /** - * @Mutation - * @param TestObject $testObject - * @return TestObject - */ - public function mutation(TestObject $testObject): TestObject - { - return $testObject; - } - - /** - * @Query - * @param int $int * @param TestObject[] $list - * @param bool|null $boolean - * @param float|null $float - * @param \DateTimeImmutable|null $dateTimeImmutable - * @param \DateTimeInterface|null $dateTime - * @param string $withDefault - * @param null|string $string - * @param ID|null $id - * @param TestEnum $enum - * @return TestObject */ - public function test(int $int, array $list, ?bool $boolean, ?float $float, ?\DateTimeImmutable $dateTimeImmutable, ?\DateTimeInterface $dateTime, string $withDefault = 'default', ?string $string = null, ID $id = null, TestEnum $enum = null): TestObject + #[Query] + public function test( + int $int, + array $list, + ?bool $boolean, + ?float $float, + ?\DateTimeImmutable $dateTimeImmutable, + ?\DateTimeInterface $dateTime, + string $withDefault = 'default', + ?string $string = null, + ID $id = null, + TestEnum $enum = null, + ): TestObject { $str = ''; foreach ($list as $test) { @@ -49,99 +41,101 @@ public function test(int $int, array $list, ?bool $boolean, ?float $float, ?\Dat return new TestObject($string.$int.$str.($boolean?'true':'false').$float.$dateTimeImmutable->format('YmdHis').$dateTime->format('YmdHis').$withDefault.($id !== null ? $id->val() : '').$enum->getValue()); } - /** - * @Query - * @Logged - * @HideIfUnauthorized() - */ + #[Query] + #[HideIfUnauthorized] + #[Logged] public function testLogged(): TestObject { return new TestObject('foo'); } - /** - * @Query - * @Right(name="CAN_FOO") - * @HideIfUnauthorized() - */ + #[Query] + #[Right(name: "CAN_FOO")] + #[HideIfUnauthorized] public function testRight(): TestObject { return new TestObject('foo'); } - /** - * @Query(outputType="ID") - */ + #[Query(outputType: 'ID')] public function testFixReturnType(): TestObject { return new TestObject('foo'); } - /** - * @Query(name="nameFromAnnotation") - */ + #[Query(name: 'nameFromAnnotation')] public function testNameFromAnnotation(): TestObject { return new TestObject('foo'); } /** - * @Query(name="arrayObject") * @return ArrayObject|TestObject[] */ + #[Query(name: 'arrayObject')] public function testArrayObject(): ArrayObject { return new ArrayObject([]); } /** - * @Query(name="arrayObjectGeneric") * @return ArrayObject */ + #[Query(name: 'arrayObjectGeneric')] public function testArrayObjectGeneric(): ArrayObject { return new ArrayObject([]); } /** - * @Query(name="iterable") * @return iterable|TestObject[] */ + #[Query(name: 'iterable')] public function testIterable(): iterable { return array(); } /** - * @Query(name="iterableGeneric") * @return iterable */ + #[Query(name: 'iterableGeneric')] public function testIterableGeneric(): iterable { return array(); } /** - * @Query(name="union") * @return TestObject|TestObject2 */ + #[Query(name: 'union')] public function testUnion() { return new TestObject2('foo'); } - /** - * @Query(outputType="[ID!]!") - */ + #[Query(outputType: '[ID!]!')] public function testFixComplexReturnType(): array { return ['42']; } - /** - * @Mutation - */ + #[Mutation] public function testVoid(): void { } + + #[Mutation] + public function testReturn(TestObject $testObject): TestObject + { + return $testObject; + } + + #[Subscription(outputType: 'ID')] + public function testSubscribe(): void + {} + + #[Subscription(outputType: 'ID')] + public function testSubscribeWithInput(TestObject $testObject): void + {} } diff --git a/tests/GlobControllerQueryProviderTest.php b/tests/GlobControllerQueryProviderTest.php index 48b5adcbb4..8f9674fc37 100644 --- a/tests/GlobControllerQueryProviderTest.php +++ b/tests/GlobControllerQueryProviderTest.php @@ -5,7 +5,6 @@ use Psr\Container\ContainerInterface; use Symfony\Component\Cache\Adapter\NullAdapter; use Symfony\Component\Cache\Psr16Cache; -use Symfony\Component\Cache\Simple\NullCache; use TheCodingMachine\GraphQLite\Fixtures\TestController; class GlobControllerQueryProviderTest extends AbstractQueryProviderTest @@ -36,7 +35,16 @@ public function has($id):bool } }; - $globControllerQueryProvider = new GlobControllerQueryProvider('TheCodingMachine\\GraphQLite\\Fixtures', $this->getFieldsBuilder(), $container, $this->getAnnotationReader(), new Psr16Cache(new NullAdapter()), null, false, false); + $globControllerQueryProvider = new GlobControllerQueryProvider( + 'TheCodingMachine\\GraphQLite\\Fixtures', + $this->getFieldsBuilder(), + $container, + $this->getAnnotationReader(), + new Psr16Cache(new NullAdapter), + null, + false, + false, + ); $queries = $globControllerQueryProvider->getQueries(); $this->assertCount(9, $queries); @@ -44,5 +52,7 @@ public function has($id):bool $mutations = $globControllerQueryProvider->getMutations(); $this->assertCount(2, $mutations); + $subscriptions = $globControllerQueryProvider->getSubscriptions(); + $this->assertCount(2, $subscriptions); } } diff --git a/tests/Integration/EndToEndTest.php b/tests/Integration/EndToEndTest.php index a00daeeeea..f58e80ffb2 100644 --- a/tests/Integration/EndToEndTest.php +++ b/tests/Integration/EndToEndTest.php @@ -2270,4 +2270,63 @@ public function testEndToEndVoidResult(): void 'deleteButton' => null, ], $this->getSuccessResult($result)); } + + public function testEndToEndSubscription(): void + { + $schema = $this->mainContainer->get(Schema::class); + assert($schema instanceof Schema); + $queryString = ' + subscription { + contactAdded { + nickName + age + } + } + '; + + $result = GraphQL::executeQuery($schema, $queryString); + + $this->assertSame([ + 'contactAdded' => [ + 'nickName' => 'foo', + 'age' => 42, + ], + ], $this->getSuccessResult($result)); + } + + public function testEndToEndSubscriptionWithInput(): void + { + $schema = $this->mainContainer->get(Schema::class); + assert($schema instanceof Schema); + $queryString = ' + subscription { + contactAddedWithFilter( + contact: { + name: "foo", + birthDate: "1942-12-24T00:00:00+00:00", + relations: [ + { + name: "bar" + } + ] + } + ) { + name, + birthDate, + relations { + name + } + } + } + '; + + $result = GraphQL::executeQuery( + $schema, + $queryString, + ); + + $this->assertSame([ + 'contactAddedWithFilter' => null, + ], $this->getSuccessResult($result)); + } } diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index d060beb932..3863b6d412 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -2,8 +2,6 @@ namespace TheCodingMachine\GraphQLite; -use PHPUnit\Framework\TestCase; - class SchemaTest extends AbstractQueryProviderTest { @@ -19,6 +17,11 @@ public function getMutations(): array { return []; } + + public function getSubscriptions(): array + { + return []; + } }; $schema = new Schema($queryProvider, $this->getTypeMapper(), $this->getTypeResolver(), $this->getRootTypeMapper()); @@ -26,11 +29,16 @@ public function getMutations(): array $fields = $schema->getQueryType()->getFields(); $this->assertArrayHasKey('dummyQuery', $fields); $resolve = $fields['dummyQuery']->resolveFn; - $this->assertSame('This is a placeholder query. Please create a query using the @Query annotation.', $resolve()); + $this->assertSame('This is a placeholder query. Please create a query using the "Query" attribute.', $resolve()); $fields = $schema->getMutationType()->getFields(); $this->assertArrayHasKey('dummyMutation', $fields); $resolve = $fields['dummyMutation']->resolveFn; - $this->assertSame('This is a placeholder mutation. Please create a mutation using the @Mutation annotation.', $resolve()); + $this->assertSame('This is a placeholder mutation. Please create a mutation using the "Mutation" attribute.', $resolve()); + + $fields = $schema->getSubscriptionType()->getFields(); + $this->assertArrayHasKey('dummySubscription', $fields); + $resolve = $fields['dummySubscription']->resolveFn; + $this->assertSame('This is a placeholder subscription. Please create a subscription using the "Subscription" attribute.', $resolve()); } } diff --git a/website/docs/README.mdx b/website/docs/README.mdx index 5ac2ccb123..0293578109 100644 --- a/website/docs/README.mdx +++ b/website/docs/README.mdx @@ -19,7 +19,8 @@ A PHP library that allows you to write your GraphQL queries in simple-to-write c * Create a complete GraphQL API by simply annotating your PHP classes * Framework agnostic, but Symfony, Laravel and PSR-15 bindings available! -* Comes with batteries included: queries, mutations, mapping of arrays / iterators, file uploads, security, validation, extendable types and more! +* Comes with batteries included: queries, mutations, subscriptions, mapping of arrays / iterators, +file uploads, security, validation, extendable types and more! ## Basic example diff --git a/website/docs/annotations-reference.md b/website/docs/annotations-reference.md index 0a1c872661..e838cbf987 100644 --- a/website/docs/annotations-reference.md +++ b/website/docs/annotations-reference.md @@ -29,6 +29,17 @@ Attribute | Compulsory | Type | Definition name | *no* | string | The name of the mutation. If skipped, the name of the method is used instead. [outputType](custom-types.mdx) | *no* | string | Forces the GraphQL output type of a query. +## @Subscription + +The `@Subscription` annotation is used to declare a GraphQL subscription. + +**Applies on**: controller methods. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +name | *no* | string | The name of the subscription. If skipped, the name of the method is used instead. +[outputType](custom-types.mdx) | *no* | string | Defines the GraphQL output type that will be sent for the subscription. + ## @Type The `@Type` annotation is used to declare a GraphQL object type. This is used with standard output @@ -67,7 +78,7 @@ Attribute | Compulsory | Type | Definition name | *no* | string | The name of the GraphQL input type generated. If not passed, the name of the class with suffix "Input" is used. If the class ends with "Input", the "Input" suffix is not added. description | *no* | string | Description of the input type in the documentation. If not passed, PHP doc comment is used. default | *no* | bool | Name of the input type represented in your GraphQL schema. Defaults to `true` *only if* the name is not specified. If `name` is specified, this will default to `false`, so must also be included for `true` when `name` is used. -update | *no* | bool | Determines if the the input represents a partial update. When set to `true` all input fields will become optional and won't have default values thus won't be set on resolve if they are not specified in the query/mutation. This primarily applies to nullable fields. +update | *no* | bool | Determines if the the input represents a partial update. When set to `true` all input fields will become optional and won't have default values thus won't be set on resolve if they are not specified in the query/mutation/subscription. This primarily applies to nullable fields. ## @Field @@ -151,7 +162,7 @@ name | *yes* | string | The name of the right. ## @FailWith The `@FailWith` annotation is used to declare a default value to return in the user is not authorized to see a specific -query / mutation / field (according to the `@Logged` and `@Right` annotations). +query/mutation/subscription/field (according to the `@Logged` and `@Right` annotations). **Applies on**: methods or properties annotated with `@Query`, `@Mutation` or `@Field` and one of `@Logged` or `@Right` annotations. @@ -161,11 +172,11 @@ value | *yes* | mixed | The value to return if the user is not au ## @HideIfUnauthorized -
This annotation only works when a Schema is used to handle exactly one use request. -If you serve your GraphQL API from long-running standalone servers (like Laravel Octane, Swoole, RoadRunner etc) and +
This annotation only works when a Schema is used to handle exactly one use request. +If you serve your GraphQL API from long-running standalone servers (like Laravel Octane, Swoole, RoadRunner etc) and share the same Schema instance between multiple requests, please avoid using @HideIfUnauthorized.
-The `@HideIfUnauthorized` annotation is used to completely hide the query / mutation / field if the user is not authorized +The `@HideIfUnauthorized` annotation is used to completely hide the query/mutation/subscription/field if the user is not authorized to access it (according to the `@Logged` and `@Right` annotations). **Applies on**: methods or properties annotated with `@Query`, `@Mutation` or `@Field` and one of `@Logged` or `@Right` annotations. @@ -175,7 +186,7 @@ to access it (according to the `@Logged` and `@Right` annotations). ## @InjectUser Use the `@InjectUser` annotation to inject an instance of the current user logged in into a parameter of your -query / mutation / field. +query/mutation/subscription/field. See [the authentication and authorization page](authentication-authorization.mdx) for more details. @@ -255,11 +266,11 @@ Attribute | Compulsory | Type | Definition Sets complexity and multipliers on fields for [automatic query complexity](operation-complexity.md#static-request-analysis). -Attribute | Compulsory | Type | Definition +Attribute | Compulsory | Type | Definition --------------------|------------|-----------------|----------------------------------------------------------------- -*complexity* | *no* | int | Complexity for that field -*multipliers* | *no* | array\ | Names of fields by value of which complexity will be multiplied -*defaultMultiplier* | *no* | int | Default multiplier value if all multipliers are missing/null +*complexity* | *no* | int | Complexity for that field +*multipliers* | *no* | array\ | Names of fields by value of which complexity will be multiplied +*defaultMultiplier* | *no* | int | Default multiplier value if all multipliers are missing/null ## @Validate diff --git a/website/docs/authentication-authorization.mdx b/website/docs/authentication-authorization.mdx index 0e6a483a59..f716d02d30 100644 --- a/website/docs/authentication-authorization.mdx +++ b/website/docs/authentication-authorization.mdx @@ -7,19 +7,20 @@ sidebar_label: Authentication and authorization import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -You might not want to expose your GraphQL API to anyone. Or you might want to keep some queries/mutations or fields -reserved to some users. +You might not want to expose your GraphQL API to anyone. Or you might want to keep some +queries/mutations/subscriptions or fields reserved to some users. -GraphQLite offers some control over what a user can do with your API. You can restrict access to resources: +GraphQLite offers some control over what a user can do with your API. You can restrict access to +resources: - based on authentication using the [`@Logged` annotation](#logged-and-right-annotations) (restrict access to logged users) - based on authorization using the [`@Right` annotation](#logged-and-right-annotations) (restrict access to logged users with certain rights). - based on fine-grained authorization using the [`@Security` annotation](fine-grained-security.mdx) (restrict access for some given resources to some users).
-GraphQLite does not have its own security mechanism. -Unless you're using our Symfony Bundle or our Laravel package, it is up to you to connect this feature to your framework's security mechanism.
-See Connecting GraphQLite to your framework's security module. + GraphQLite does not have its own security mechanism. + Unless you're using our Symfony Bundle or our Laravel package, it is up to you to connect this feature to your framework's security mechanism.
+ See Connecting GraphQLite to your framework's security module.
## `@Logged` and `@Right` annotations @@ -93,11 +94,15 @@ has the `CAN_VIEW_USER_LIST` right. * `@Mutation` annotations * `@Field` annotations -
By default, if a user tries to access an unauthorized query/mutation/field, an error is raised and the query fails.
+
+ By default, if a user tries to access an unauthorized query/mutation/subscription/field, an error is + raised and the query fails. +
## Not throwing errors -If you do not want an error to be thrown when a user attempts to query a field/query/mutation he has no access to, you can use the `@FailWith` annotation. +If you do not want an error to be thrown when a user attempts to query a field/query/mutation/subscription +they have no access to, you can use the `@FailWith` annotation. The `@FailWith` annotation contains the value that will be returned for users with insufficient rights. @@ -226,9 +231,9 @@ The object injected as the current user depends on your framework. It is in fact ["authentication service" configured in GraphQLite](implementing-security.md). If user is not authenticated and parameter's type is not nullable, an authorization exception is thrown, similar to `@Logged` annotation. -## Hiding fields / queries / mutations +## Hiding fields / queries / mutations / subscriptions -By default, a user analysing the GraphQL schema can see all queries/mutations/types available. +By default, a user analysing the GraphQL schema can see all queries/mutations/subscriptions/types available. Some will be available to him and some won't. If you want to add an extra level of security (or if you want your schema to be kept secret to unauthorized users), diff --git a/website/docs/custom-types.mdx b/website/docs/custom-types.mdx index 3943b22519..800ab2846c 100644 --- a/website/docs/custom-types.mdx +++ b/website/docs/custom-types.mdx @@ -98,6 +98,7 @@ You can use the `outputType` attribute in the following annotations: * `@Query` * `@Mutation` +* `@Subscription` * `@Field` * `@SourceField` * `@MagicField` diff --git a/website/docs/subscriptions.mdx b/website/docs/subscriptions.mdx new file mode 100644 index 0000000000..bcfd118aac --- /dev/null +++ b/website/docs/subscriptions.mdx @@ -0,0 +1,53 @@ +--- +id: subscriptions +title: Subscriptions +sidebar_label: Subscriptions +--- + +In GraphQLite, subscriptions are created [like queries](queries.mdx) or [mutations](mutations.mdx). + +To create a subscription, you must annotate a method in a controller with the `#[Subscription]` attribute. + +For instance: + +```php +namespace App\Controller; + +use TheCodingMachine\GraphQLite\Annotations\Mutation; + +class ProductController +{ + #[Subscription(outputType: 'Product')] + public function productAdded(?ID $categoryId = null): void + { + // Some code that sets up any connections, stores the subscription details, etc. + } +} +``` + +As you will notice in the above example, we're returning `void`. In general, this is probably the +correct return type. + +You could, however, type the `Product` as the return type of the method, instead +of using the `outputType` argument on the `#[Subscription]` attribute. This means you +would have to return an instance of `Product` from the method though. One exception here, is if +you intend to use PHP for your long-running streaming process, you could block the process inside +the controller and basically never return anything from the method, just terminating the +connection/stream when it breaks, or when the client disconnects. + +Most implementations will want to offload the actual real-time streaming connection to a better suited +technology, like SSE (server-sent events), WebSockets, etc. GraphQLite does not make any assumptions +here. Therefore, it's most practical to return `void` from the controller method. Since GraphQL +is a strictly typed spec, we cannot return anything other than the defined `outputType` from the request. +That would be a violation of the GraphQL specification. Returning `void`, which is translated to `null` +in the GraphQL response body, allows for us to complete the request and terminate the PHP process. + +We recommend using response headers to pass back any necessary information realted to the subscription. +This might be a subscription ID, a streaming server URL to connect to, or whatever you need to pass +back to the client. + +
+ In the future, it may make sense to implement streaming servers directly into GraphQLite, especially + as PHP progresses with async and parallel processing. At this time, we might consider returning a + `Generator` (or `Fiber`) from the controller method. +
diff --git a/website/docs/validation.mdx b/website/docs/validation.mdx index ee1a0e93a9..dfb2c925dd 100644 --- a/website/docs/validation.mdx +++ b/website/docs/validation.mdx @@ -199,7 +199,7 @@ If a validation fails, GraphQLite will return the failed validations in the "err ``` -### Using the validator directly on a query / mutation / factory ... +### Using the validator directly on a query / mutation / subscription / factory ... If the data entered by the user is mapped to an object, please use the "validator" instance directly as explained in the last chapter. It is a best practice to put your validation layer as close as possible to your domain model. diff --git a/website/sidebars.json b/website/sidebars.json index 68ec666d7f..571f243dba 100755 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -13,6 +13,7 @@ "Usage": [ "queries", "mutations", + "subscriptions", "type-mapping", "autowiring", "extend-type", From 6f31b082188c9759a842ddcd4e16e67f604b6559 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Feb 2024 15:44:31 -0500 Subject: [PATCH 049/108] Bump codecov/codecov-action from 3.1.5 to 4.0.1 (#654) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3.1.5 to 4.0.1. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v3.1.5...v4.0.1) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 65dc772998..8951218123 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -84,7 +84,7 @@ jobs: name: "codeCoverage" path: "build" - - uses: codecov/codecov-action@v3.1.5 # upload the coverage to codecov + - uses: codecov/codecov-action@v4.0.1 # upload the coverage to codecov with: fail_ci_if_error: true # optional (default = false) # Do not upload in forks, and only on php8, latest deps From 8c97363696c47d9951975611e5a38cd82d26bdd3 Mon Sep 17 00:00:00 2001 From: Yurii <141632421+fogrye@users.noreply.github.com> Date: Sun, 11 Feb 2024 23:38:36 +0100 Subject: [PATCH 050/108] fix: RecursiveTypeMapper returns exception which can't be handled by StandardServer (#646) * fix: RecursiveTypeMapper returns exception which can't be handled by StandardServer RecursiveTypeMapper throws CannotMapTypeException when fails to find class by name. This exception goes through the GraphQl executor instead of catching and wrapping into ExecutionResult. Non wrapped exception does not trigger HttpCodeDecider. Schema test trust more to input than configuration which is not optimal, great possibility to improve configuration validation. Fixes: #336 * feat: new special Exception class when request type unknown Fixes: #336 * fix: move asserts Fixes: #336 * polish changes Fixes: #336 --- src/Mappers/RecursiveTypeMapper.php | 2 +- src/Mappers/TypeNotFoundException.php | 23 ++++++++ tests/Mappers/RecursiveTypeMapperTest.php | 6 +- tests/SchemaFactoryTest.php | 69 ++++++++++++++--------- 4 files changed, 70 insertions(+), 30 deletions(-) create mode 100644 src/Mappers/TypeNotFoundException.php diff --git a/src/Mappers/RecursiveTypeMapper.php b/src/Mappers/RecursiveTypeMapper.php index 0b3415740f..f55c341419 100644 --- a/src/Mappers/RecursiveTypeMapper.php +++ b/src/Mappers/RecursiveTypeMapper.php @@ -521,6 +521,6 @@ public function mapNameToType(string $typeName): Type&NamedType return $this->mapClassToInterfaceOrType($className, null); } - throw CannotMapTypeException::createForName($typeName); + throw TypeNotFoundException::createError($typeName); } } diff --git a/src/Mappers/TypeNotFoundException.php b/src/Mappers/TypeNotFoundException.php new file mode 100644 index 0000000000..2f5df7d0bf --- /dev/null +++ b/src/Mappers/TypeNotFoundException.php @@ -0,0 +1,23 @@ +assertSame('ClassA', $recursiveMapper->mapNameToType('ClassA')->name); $this->assertSame('ClassAInterface', $recursiveMapper->mapNameToType('ClassAInterface')->name); - $this->expectException(CannotMapTypeException::class); + $this->expectException(TypeNotFoundException::class); $recursiveMapper->mapNameToType('NotExists'); } @@ -233,6 +233,8 @@ public function testDuplicateDetection(): void /** * Tests that the RecursiveTypeMapper behaves correctly if there are no types to map. + * + * @see \GraphQL\Server\Helper::promiseToExecuteOperation() */ public function testMapNoTypes(): void { @@ -244,7 +246,7 @@ public function testMapNoTypes(): void $this->getAnnotationReader() ); - $this->expectException(CannotMapTypeException::class); + $this->expectException(TypeNotFoundException::class); $recursiveTypeMapper->mapNameToType('Foo'); } diff --git a/tests/SchemaFactoryTest.php b/tests/SchemaFactoryTest.php index 090f8d91fd..908f265f57 100644 --- a/tests/SchemaFactoryTest.php +++ b/tests/SchemaFactoryTest.php @@ -1,8 +1,12 @@ addControllerNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers'); $factory->addTypeNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); - $factory->setDoctrineAnnotationReader(new \Doctrine\Common\Annotations\AnnotationReader()) + $factory->setDoctrineAnnotationReader(new AnnotationReader()) ->setAuthenticationService(new VoidAuthenticationService()) ->setAuthorizationService(new VoidAuthorizationService()) ->setNamingStrategy(new NamingStrategy()) @@ -89,8 +90,8 @@ public function testClassNameMapperInjectionWithValidMapper(): void $factory = new SchemaFactory( new Psr16Cache(new ArrayAdapter()), new BasicAutoWiringContainer( - new EmptyContainer() - ) + new EmptyContainer(), + ), ); $factory->setAuthenticationService(new VoidAuthenticationService()) ->setAuthorizationService(new VoidAuthorizationService()) @@ -132,8 +133,8 @@ public function testClassNameMapperInjectionWithInvalidMapper(): void $factory = new SchemaFactory( new Psr16Cache(new ArrayAdapter()), new BasicAutoWiringContainer( - new EmptyContainer() - ) + new EmptyContainer(), + ), ); $factory->setAuthenticationService(new VoidAuthenticationService()) ->setAuthorizationService(new VoidAuthorizationService()) @@ -141,8 +142,7 @@ public function testClassNameMapperInjectionWithInvalidMapper(): void ->addControllerNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers') ->addTypeNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); - $this->expectException(CannotMapTypeException::class); - $this->doTestSchema($factory->createSchema()); + $this->doTestSchemaWithError($factory->createSchema()); } public function testException(): void @@ -168,9 +168,8 @@ public function testException2(): void $factory->createSchema(); } - private function doTestSchema(Schema $schema): void + private function execTestQuery(Schema $schema): ExecutionResult { - $schema->assertValid(); $queryString = ' @@ -185,25 +184,41 @@ private function doTestSchema(Schema $schema): void } '; - $result = GraphQL::executeQuery( + return GraphQL::executeQuery( $schema, - $queryString + $queryString, ); + } + + private function doTestSchemaWithError(Schema $schema): void + { + $result = $this->execTestQuery($schema); + $resultArr = $result->toArray(DebugFlag::RETHROW_INTERNAL_EXCEPTIONS); + $this->assertArrayHasKey('errors', $resultArr); + $this->assertArrayNotHasKey('data', $resultArr); + $this->assertCount(1, $resultArr); + $this->assertSame('Unknown type "User"', $resultArr['errors'][0]['message']); + } + private function doTestSchema(Schema $schema): void + { + $result = $this->execTestQuery($schema); + $resultArr = $result->toArray(DebugFlag::RETHROW_INTERNAL_EXCEPTIONS); + $this->assertArrayHasKey('data', $resultArr); $this->assertSame([ 'contacts' => [ [ 'name' => 'Joe', - 'uppercaseName' => 'JOE' + 'uppercaseName' => 'JOE', ], [ 'name' => 'Bill', 'uppercaseName' => 'BILL', - 'email' => 'bill@example.com' - ] + 'email' => 'bill@example.com', + ], - ] - ], $result->toArray(DebugFlag::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], + ], $resultArr['data']); } public function testDuplicateQueryException(): void @@ -211,8 +226,8 @@ public function testDuplicateQueryException(): void $factory = new SchemaFactory( new Psr16Cache(new ArrayAdapter()), new BasicAutoWiringContainer( - new EmptyContainer() - ) + new EmptyContainer(), + ), ); $factory->setAuthenticationService(new VoidAuthenticationService()) ->setAuthorizationService(new VoidAuthorizationService()) @@ -229,7 +244,7 @@ public function testDuplicateQueryException(): void '; GraphQL::executeQuery( $schema, - $queryString + $queryString, ); } @@ -238,8 +253,8 @@ public function testDuplicateQueryInTwoControllersException(): void $factory = new SchemaFactory( new Psr16Cache(new ArrayAdapter()), new BasicAutoWiringContainer( - new EmptyContainer() - ) + new EmptyContainer(), + ), ); $factory->setAuthenticationService(new VoidAuthenticationService()) ->setAuthorizationService(new VoidAuthorizationService()) @@ -256,7 +271,7 @@ public function testDuplicateQueryInTwoControllersException(): void '; GraphQL::executeQuery( $schema, - $queryString + $queryString, ); } } From c37806399303da22136cedada5b0a6852c8112c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 11 Feb 2024 18:25:39 -0500 Subject: [PATCH 051/108] Update psr/http-message requirement from ^1.0.1 to ^1.0.1 || ^2.0 (#598) Updates the requirements on [psr/http-message](https://github.com/php-fig/http-message) to permit the latest version. - [Release notes](https://github.com/php-fig/http-message/releases) - [Changelog](https://github.com/php-fig/http-message/blob/master/CHANGELOG.md) - [Commits](https://github.com/php-fig/http-message/compare/1.0.1...2.0) --- updated-dependencies: - dependency-name: psr/http-message dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 242e5bb86d..24dab77d1d 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "phpdocumentor/type-resolver": "^1.4", "psr/container": "^1.1 || ^2", "psr/http-factory": "^1", - "psr/http-message": "^1.0.1", + "psr/http-message": "^1.0.1 || ^2.0", "psr/http-server-handler": "^1", "psr/http-server-middleware": "^1", "psr/simple-cache": "^1.0.1 || ^2 || ^3", From ed3cf866358dfb9e8fd91dcdcc58ce44e9d21aa8 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Sun, 11 Feb 2024 19:40:54 -0500 Subject: [PATCH 052/108] Improve and fix CI workflows for CS checks, code coverage enabling, etc (#655) * Update CI configs for 8.3 support and updated actions packages * Additional CI improvements * Fixed PHPCS errors --- .github/workflows/continuous_integration.yml | 8 +- ...ggregateControllerQueryProviderFactory.php | 3 +- src/Exceptions/GraphQLAggregateException.php | 1 + src/FailedResolvingInputType.php | 5 +- src/FieldsBuilder.php | 306 ++++++++++++------ src/GlobControllerQueryProvider.php | 9 +- src/InputField.php | 6 +- src/InputFieldDescriptor.php | 8 - src/InputTypeUtils.php | 1 - src/Mappers/AbstractTypeMapper.php | 17 +- src/Mappers/GlobAnnotationsCache.php | 13 +- src/Mappers/GlobTypeMapper.php | 43 ++- src/Mappers/Root/EnumTypeMapper.php | 25 +- src/Mappers/Root/MyCLabsEnumTypeMapper.php | 28 +- .../Root/RootTypeMapperFactoryContext.php | 2 + src/Mappers/StaticClassListTypeMapper.php | 36 ++- .../AuthorizationInputFieldMiddleware.php | 2 - src/Middlewares/MagicPropertyResolver.php | 9 +- .../SourceConstructorParameterResolver.php | 12 +- src/Middlewares/SourceMethodResolver.php | 5 +- src/QueryFieldDescriptor.php | 7 - src/SchemaFactory.php | 2 - src/Types/EnumType.php | 14 +- src/Types/MutableInterfaceType.php | 1 + src/Types/MutableObjectType.php | 1 + .../ResolvableMutableInputObjectType.php | 8 +- src/Utils/Namespaces/NS.php | 1 - 27 files changed, 378 insertions(+), 195 deletions(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 8951218123..224ffac683 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: install-args: ['', '--prefer-lowest'] - php-version: ['8.1', '8.2'] + php-version: ['8.1', '8.2', '8.3'] fail-fast: false steps: @@ -76,16 +76,16 @@ jobs: - name: "Run coding standard checks with squizlabs/php_codesniffer" run: "composer cs-check" - if: ${{ matrix.php-version == '7.2' }} # Do not suggest using features after 7.2 - name: "Archive code coverage results" - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: "codeCoverage" path: "build" + overwrite: true - uses: codecov/codecov-action@v4.0.1 # upload the coverage to codecov with: fail_ci_if_error: true # optional (default = false) # Do not upload in forks, and only on php8, latest deps - if: ${{ github.repository == 'thecodingmachine/graphqlite' && matrix.php-version == '8.0' && matrix.install-args == '' }} + if: ${{ github.repository == 'thecodingmachine/graphqlite' && matrix.install-args == '' }} diff --git a/src/AggregateControllerQueryProviderFactory.php b/src/AggregateControllerQueryProviderFactory.php index 9a14dcd062..607186eb07 100644 --- a/src/AggregateControllerQueryProviderFactory.php +++ b/src/AggregateControllerQueryProviderFactory.php @@ -18,7 +18,8 @@ class AggregateControllerQueryProviderFactory implements QueryProviderFactoryInt public function __construct( private readonly iterable $controllers, private readonly ContainerInterface $controllersContainer, - ) {} + ) { + } public function create(FactoryContext $context): QueryProviderInterface { diff --git a/src/Exceptions/GraphQLAggregateException.php b/src/Exceptions/GraphQLAggregateException.php index ac6244259e..d63ca3a0c3 100644 --- a/src/Exceptions/GraphQLAggregateException.php +++ b/src/Exceptions/GraphQLAggregateException.php @@ -23,6 +23,7 @@ class GraphQLAggregateException extends Exception implements GraphQLAggregateExc public function __construct(iterable $exceptions = []) { parent::__construct('Many exceptions have be thrown:'); + foreach ($exceptions as $exception) { $this->add($exception); } diff --git a/src/FailedResolvingInputType.php b/src/FailedResolvingInputType.php index 3ae93cff10..8f3d625de7 100644 --- a/src/FailedResolvingInputType.php +++ b/src/FailedResolvingInputType.php @@ -4,15 +4,16 @@ namespace TheCodingMachine\GraphQLite; +use ArgumentCountError; use RuntimeException; use function sprintf; class FailedResolvingInputType extends RuntimeException { - public static function createForMissingConstructorParameter(\ArgumentCountError $original): self + public static function createForMissingConstructorParameter(ArgumentCountError $original): self { - return new self(sprintf("%s. It should be mapped as required field.", $original->getMessage()), previous: $original); + return new self(sprintf('%s. It should be mapped as required field.', $original->getMessage()), previous: $original); } public static function createForDecorator(string $class): self diff --git a/src/FieldsBuilder.php b/src/FieldsBuilder.php index 03abb2fd68..878f837c9c 100644 --- a/src/FieldsBuilder.php +++ b/src/FieldsBuilder.php @@ -4,26 +4,7 @@ namespace TheCodingMachine\GraphQLite; -use const PHP_EOL; use Doctrine\Common\Annotations\AnnotationException; -use function array_diff_key; -use function array_fill_keys; -use function array_intersect_key; -use function array_keys; -use function array_merge; -use function array_shift; -use function array_slice; -use function assert; -use function count; -use function get_parent_class; -use function in_array; -use function is_callable; -use function is_string; -use function key; -use function reset; -use function rtrim; -use function str_starts_with; -use function trim; use GraphQL\Type\Definition\FieldDefinition; use GraphQL\Type\Definition\InputType; use GraphQL\Type\Definition\NonNull; @@ -38,10 +19,8 @@ use ReflectionParameter; use ReflectionProperty; use TheCodingMachine\GraphQLite\Annotations\AbstractRequest; -use TheCodingMachine\GraphQLite\Annotations\Exceptions\IncompatibleAnnotationsException; use TheCodingMachine\GraphQLite\Annotations\Exceptions\InvalidParameterException; use TheCodingMachine\GraphQLite\Annotations\Field; -use TheCodingMachine\GraphQLite\Annotations\MiddlewareAnnotationInterface; use TheCodingMachine\GraphQLite\Annotations\Mutation; use TheCodingMachine\GraphQLite\Annotations\ParameterAnnotations; use TheCodingMachine\GraphQLite\Annotations\Query; @@ -53,7 +32,6 @@ use TheCodingMachine\GraphQLite\Mappers\Parameters\ParameterMiddlewareInterface; use TheCodingMachine\GraphQLite\Mappers\Parameters\TypeHandler; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapperInterface; - use TheCodingMachine\GraphQLite\Mappers\Root\RootTypeMapperInterface; use TheCodingMachine\GraphQLite\Middlewares\FieldHandlerInterface; use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewareInterface; @@ -72,10 +50,29 @@ use TheCodingMachine\GraphQLite\Reflection\CachedDocBlockFactory; use TheCodingMachine\GraphQLite\Types\ArgumentResolver; use TheCodingMachine\GraphQLite\Types\MutableObjectType; - use TheCodingMachine\GraphQLite\Types\TypeResolver; use TheCodingMachine\GraphQLite\Utils\PropertyAccessor; +use function array_diff_key; +use function array_fill_keys; +use function array_intersect_key; +use function array_keys; +use function array_merge; +use function array_shift; +use function array_slice; +use function assert; +use function count; +use function get_parent_class; +use function in_array; +use function is_string; +use function key; +use function reset; +use function rtrim; +use function str_starts_with; +use function trim; + +use const PHP_EOL; + /** * A class in charge if returning list of fields for queries / mutations / entities / input types */ @@ -84,15 +81,15 @@ class FieldsBuilder private TypeHandler $typeMapper; public function __construct( - private readonly AnnotationReader $annotationReader, - private readonly RecursiveTypeMapperInterface $recursiveTypeMapper, - private readonly ArgumentResolver $argumentResolver, - private readonly TypeResolver $typeResolver, - private readonly CachedDocBlockFactory $cachedDocBlockFactory, - private readonly NamingStrategyInterface $namingStrategy, - private readonly RootTypeMapperInterface $rootTypeMapper, - private readonly ParameterMiddlewareInterface $parameterMapper, - private readonly FieldMiddlewareInterface $fieldMiddleware, + private readonly AnnotationReader $annotationReader, + private readonly RecursiveTypeMapperInterface $recursiveTypeMapper, + private readonly ArgumentResolver $argumentResolver, + private readonly TypeResolver $typeResolver, + private readonly CachedDocBlockFactory $cachedDocBlockFactory, + private readonly NamingStrategyInterface $namingStrategy, + private readonly RootTypeMapperInterface $rootTypeMapper, + private readonly ParameterMiddlewareInterface $parameterMapper, + private readonly FieldMiddlewareInterface $fieldMiddleware, private readonly InputFieldMiddlewareInterface $inputFieldMiddleware, ) { @@ -191,16 +188,38 @@ public function getInputFields(string $className, string $inputName, bool $isUpd } if ($reflector instanceof ReflectionMethod) { - $fields = $this->getInputFieldsByMethodAnnotations($className, $refClass, $reflector, Field::class, false, $defaultProperties, $inputName, $isUpdate); + $fields = $this->getInputFieldsByMethodAnnotations( + $className, + $refClass, + $reflector, + Field::class, + false, + $defaultProperties, + $inputName, + $isUpdate, + ); } else { - $fields = $this->getInputFieldsByPropertyAnnotations($className, $refClass, $reflector, Field::class, $defaultProperties, $inputName, $isUpdate); + $fields = $this->getInputFieldsByPropertyAnnotations( + $className, + $refClass, + $reflector, + Field::class, + $defaultProperties, + $inputName, + $isUpdate, + ); } $duplicates = array_intersect_key($reflectorByFields, $fields); if ($duplicates) { $name = key($duplicates); assert(is_string($name)); - throw DuplicateMappingException::createForQuery($refClass->getName(), $name, $reflectorByFields[$name], $reflector); + throw DuplicateMappingException::createForQuery( + $refClass->getName(), + $name, + $reflectorByFields[$name], + $reflector, + ); } $reflectorByFields = array_merge( @@ -211,7 +230,8 @@ public function getInputFields(string $className, string $inputName, bool $isUpd $inputFields = array_merge($inputFields, $fields); } - // Make sure @Field annotations applied to parent's private properties are taken into account as well. + // Make sure @Field annotations applied to parent's private properties are taken into + // account as well. $parent = $refClass->getParentClass(); if ($parent) { $parentFields = $this->getInputFields($parent->getName(), $inputName, $isUpdate); @@ -230,7 +250,12 @@ public function getInputFields(string $className, string $inputName, bool $isUpd */ public function getSelfFields(string $className, string|null $typeName = null): array { - $fieldAnnotations = $this->getFieldsByAnnotations($className, Annotations\Field::class, false, $typeName); + $fieldAnnotations = $this->getFieldsByAnnotations( + $className, + Annotations\Field::class, + false, + $typeName, + ); $refClass = new ReflectionClass($className); @@ -314,16 +339,34 @@ private function getFieldsByAnnotations($controller, string $annotationName, boo } if ($reflector instanceof ReflectionMethod) { - $fields = $this->getFieldsByMethodAnnotations($controller, $refClass, $reflector, $annotationName, $injectSource, $typeName); + $fields = $this->getFieldsByMethodAnnotations( + $controller, + $refClass, + $reflector, + $annotationName, + $injectSource, + $typeName, + ); } else { - $fields = $this->getFieldsByPropertyAnnotations($controller, $refClass, $reflector, $annotationName, $typeName); + $fields = $this->getFieldsByPropertyAnnotations( + $controller, + $refClass, + $reflector, + $annotationName, + $typeName, + ); } $duplicates = array_intersect_key($reflectorByFields, $fields); if ($duplicates) { $name = key($duplicates); assert(is_string($name)); - throw DuplicateMappingException::createForQuery($refClass->getName(), $name, $reflectorByFields[$name], $reflector); + throw DuplicateMappingException::createForQuery( + $refClass->getName(), + $name, + $reflectorByFields[$name], + $reflector, + ); } $reflectorByFields = array_merge( @@ -346,7 +389,14 @@ private function getFieldsByAnnotations($controller, string $annotationName, boo * * @throws AnnotationException */ - private function getFieldsByMethodAnnotations(string|object $controller, ReflectionClass $refClass, ReflectionMethod $refMethod, string $annotationName, bool $injectSource, string|null $typeName = null): array + private function getFieldsByMethodAnnotations( + string|object $controller, + ReflectionClass $refClass, + ReflectionMethod $refMethod, + string $annotationName, + bool $injectSource, + string|null $typeName = null, + ): array { $fields = []; @@ -360,7 +410,7 @@ private function getFieldsByMethodAnnotations(string|object $controller, Reflect continue; } $for = $queryAnnotation->getFor(); - if ($typeName && $for && !in_array($typeName, $for)) { + if ($typeName && $for && ! in_array($typeName, $for)) { continue; } @@ -371,7 +421,7 @@ private function getFieldsByMethodAnnotations(string|object $controller, Reflect $name = $queryAnnotation->getName() ?: $this->namingStrategy->getFieldNameFromMethodName($methodName); - if (!$description) { + if (! $description) { $description = $docBlockObj->getSummary() . "\n" . $docBlockObj->getDescription()->render(); } @@ -407,9 +457,9 @@ private function getFieldsByMethodAnnotations(string|object $controller, Reflect $args = ['__graphqlite_prefectData' => $prefetchDataParameter, ...$args]; } - $resolver = is_string($controller) ? - new SourceMethodResolver($refMethod) : - new ServiceResolver([$controller, $methodName]); + $resolver = is_string($controller) + ? new SourceMethodResolver($refMethod) + : new ServiceResolver([$controller, $methodName]); $fieldDescriptor = new QueryFieldDescriptor( name: $name, @@ -452,7 +502,13 @@ public function handle(QueryFieldDescriptor $fieldDescriptor): FieldDefinition|n * * @throws AnnotationException */ - private function getFieldsByPropertyAnnotations(string|object $controller, ReflectionClass $refClass, ReflectionProperty $refProperty, string $annotationName, string|null $typeName = null): array + private function getFieldsByPropertyAnnotations( + string|object $controller, + ReflectionClass $refClass, + ReflectionProperty $refProperty, + string $annotationName, + string|null $typeName = null, + ): array { $fields = []; $annotations = $this->annotationReader->getPropertyAnnotations($refProperty, $annotationName); @@ -461,7 +517,7 @@ private function getFieldsByPropertyAnnotations(string|object $controller, Refle if ($queryAnnotation instanceof Field) { $for = $queryAnnotation->getFor(); - if ($typeName && $for && !in_array($typeName, $for)) { + if ($typeName && $for && ! in_array($typeName, $for)) { continue; } @@ -472,7 +528,7 @@ private function getFieldsByPropertyAnnotations(string|object $controller, Refle $name = $queryAnnotation->getName() ?: $refProperty->getName(); - if (!$description) { + if (! $description) { $description = $docBlock->getSummary() . PHP_EOL . $docBlock->getDescription()->render(); /** @var Var_[] $varTags */ @@ -492,9 +548,9 @@ private function getFieldsByPropertyAnnotations(string|object $controller, Refle } $originalResolver = new SourcePropertyResolver($refProperty); - $resolver = is_string($controller) ? - $originalResolver : - fn () => PropertyAccessor::getValue($controller, $refProperty->getName()); + $resolver = is_string($controller) + ? $originalResolver + : static fn () => PropertyAccessor::getValue($controller, $refProperty->getName()); $fieldDescriptor = new QueryFieldDescriptor( name: $name, @@ -537,7 +593,10 @@ public function handle(QueryFieldDescriptor $fieldDescriptor): FieldDefinition|n * @throws CannotMapTypeExceptionInterface * @throws ReflectionException */ - private function getQueryFieldsFromSourceFields(array $sourceFields, ReflectionClass $refClass): array + private function getQueryFieldsFromSourceFields( + array $sourceFields, + ReflectionClass $refClass, + ): array { if (empty($sourceFields)) { return []; @@ -555,7 +614,7 @@ private function getQueryFieldsFromSourceFields(array $sourceFields, ReflectionC $typeName = $extendTypeField->getName(); assert($typeName !== null); $targetedType = $this->recursiveTypeMapper->mapNameToType($typeName); - if (!$targetedType instanceof MutableObjectType) { + if (! $targetedType instanceof MutableObjectType) { throw CannotMapTypeException::extendTypeWithBadTargetedClass($refClass->getName(), $extendTypeField); } $objectClass = $targetedType->getMappedClassName(); @@ -569,26 +628,24 @@ private function getQueryFieldsFromSourceFields(array $sourceFields, ReflectionC $objectRefClass = new ReflectionClass($objectClass); - $oldDeclaringClass = null; - $context = null; $queryList = []; - foreach ($sourceFields as $sourceField) { - if (!$sourceField->shouldFetchFromMagicProperty()) { + if (! $sourceField->shouldFetchFromMagicProperty()) { try { - $refMethod = $this->getMethodFromPropertyName($objectRefClass, $sourceField->getSourceName() ?? $sourceField->getName()); + $refMethod = $this->getMethodFromPropertyName( + $objectRefClass, + $sourceField->getSourceName() ?? $sourceField->getName(), + ); } catch (FieldNotFoundException $e) { throw FieldNotFoundException::wrapWithCallerInfo($e, $refClass->getName()); } - $methodName = $refMethod->getName(); - $docBlockObj = $this->cachedDocBlockFactory->getDocBlock($refMethod); $docBlockComment = rtrim($docBlockObj->getSummary() . "\n" . $docBlockObj->getDescription()->render()); $deprecated = $docBlockObj->getTagsByName('deprecated'); if (count($deprecated) >= 1) { - $deprecationReason = trim((string)$deprecated[0]); + $deprecationReason = trim((string) $deprecated[0]); } $description = $sourceField->getDescription() ?? $docBlockComment; @@ -664,7 +721,9 @@ public function handle(QueryFieldDescriptor $fieldDescriptor): FieldDefinition|n * @throws MissingAnnotationException * @throws MissingMagicGetException */ - private function getMagicGetMethodFromSourceClassOrProxy(ReflectionClass $proxyRefClass): ReflectionMethod + private function getMagicGetMethodFromSourceClassOrProxy( + ReflectionClass $proxyRefClass, + ): ReflectionMethod { $magicGet = '__get'; if ($proxyRefClass->hasMethod($magicGet)) { @@ -678,7 +737,7 @@ private function getMagicGetMethodFromSourceClassOrProxy(ReflectionClass $proxyR $sourceClassName = $typeField->getClass(); $sourceRefClass = new ReflectionClass($sourceClassName); - if (!$sourceRefClass->hasMethod($magicGet)) { + if (! $sourceRefClass->hasMethod($magicGet)) { throw MissingMagicGetException::cannotFindMagicGet($sourceClassName); } @@ -686,7 +745,11 @@ private function getMagicGetMethodFromSourceClassOrProxy(ReflectionClass $proxyR } /** @param ReflectionClass $refClass */ - private function resolveOutputType(string $outputType, ReflectionClass $refClass, SourceFieldInterface $sourceField): OutputType&Type + private function resolveOutputType( + string $outputType, + ReflectionClass $refClass, + SourceFieldInterface $sourceField, + ): OutputType&Type { try { return $this->typeResolver->mapNameToOutputType($outputType); @@ -697,7 +760,11 @@ private function resolveOutputType(string $outputType, ReflectionClass $refClass } /** @param ReflectionClass $refClass */ - private function resolvePhpType(string $phpTypeStr, ReflectionClass $refClass, ReflectionMethod $refMethod): OutputType&Type + private function resolvePhpType( + string $phpTypeStr, + ReflectionClass $refClass, + ReflectionMethod $refMethod, + ): OutputType&Type { $typeResolver = new \phpDocumentor\Reflection\TypeResolver(); @@ -716,13 +783,16 @@ private function resolvePhpType(string $phpTypeStr, ReflectionClass $refClass, R * * @template T of object */ - private function getMethodFromPropertyName(ReflectionClass $reflectionClass, string $propertyName): ReflectionMethod + private function getMethodFromPropertyName( + ReflectionClass $reflectionClass, + string $propertyName, + ): ReflectionMethod { if ($reflectionClass->hasMethod($propertyName)) { $methodName = $propertyName; } else { $methodName = PropertyAccessor::findGetter($reflectionClass->getName(), $propertyName); - if (!$methodName) { + if (! $methodName) { throw FieldNotFoundException::missingField($reflectionClass->getName(), $propertyName); } } @@ -735,16 +805,22 @@ private function getMethodFromPropertyName(ReflectionClass $reflectionClass, str * * @return array */ - private function mapParameters(array $refParameters, DocBlock $docBlock, SourceFieldInterface|null $sourceField = null): array + private function mapParameters( + array $refParameters, + DocBlock $docBlock, + SourceFieldInterface|null $sourceField = null, + ): array { if (empty($refParameters)) { return []; } + $additionalParameterAnnotations = $sourceField?->getParameterAnnotations() ?? []; - $docBlockTypes = []; /** @var DocBlock\Tags\Param[] $paramTags */ $paramTags = $docBlock->getTagsByName('param'); + + $docBlockTypes = []; foreach ($paramTags as $paramTag) { $docBlockTypes[$paramTag->getVariableName()] = $paramTag->getType(); } @@ -752,26 +828,38 @@ private function mapParameters(array $refParameters, DocBlock $docBlock, SourceF $parameterAnnotationsPerParameter = $this->annotationReader->getParameterAnnotationsPerParameter($refParameters); foreach ($refParameters as $parameter) { - $parameterAnnotations = $parameterAnnotationsPerParameter[$parameter->getName()] ?? new ParameterAnnotations([]); - //$parameterAnnotations = $this->annotationReader->getParameterAnnotations($parameter); - if (!empty($additionalParameterAnnotations[$parameter->getName()])) { + $parameterAnnotations = $parameterAnnotationsPerParameter[$parameter->getName()] + ?? new ParameterAnnotations([]); + + if (! empty($additionalParameterAnnotations[$parameter->getName()])) { $parameterAnnotations->merge($additionalParameterAnnotations[$parameter->getName()]); unset($additionalParameterAnnotations[$parameter->getName()]); } - $parameterObj = $this->parameterMapper->mapParameter($parameter, $docBlock, $docBlockTypes[$parameter->getName()] ?? null, $parameterAnnotations, $this->typeMapper); + $parameterObj = $this->parameterMapper->mapParameter( + $parameter, + $docBlock, + $docBlockTypes[$parameter->getName()] ?? null, + $parameterAnnotations, + $this->typeMapper, + ); $args[$parameter->getName()] = $parameterObj; } - // Sanity check, are the parameters declared in $additionalParameterAnnotations available in $refParameters? - if (!empty($additionalParameterAnnotations)) { + // Sanity check, are the parameters declared in $additionalParameterAnnotations available + // in $refParameters? + if (! empty($additionalParameterAnnotations)) { $refParameter = reset($refParameters); foreach ($additionalParameterAnnotations as $parameterName => $parameterAnnotations) { foreach ($parameterAnnotations->getAllAnnotations() as $annotation) { $refMethod = $refParameter->getDeclaringFunction(); assert($refMethod instanceof ReflectionMethod); - throw InvalidParameterException::parameterNotFoundFromSourceField($refParameter->getName(), $annotation::class, $refMethod); + throw InvalidParameterException::parameterNotFoundFromSourceField( + $refParameter->getName(), + $annotation::class, + $refMethod, + ); } } } @@ -786,7 +874,7 @@ private function getDeprecationReason(DocBlock $docBlockObj): string|null { $deprecated = $docBlockObj->getTagsByName('deprecated'); if (count($deprecated) >= 1) { - return trim((string)$deprecated[0]); + return trim((string) $deprecated[0]); } return null; @@ -812,7 +900,12 @@ private function getPrefetchParameter( try { $prefetchRefMethod = $refClass->getMethod($prefetchMethodName); } catch (ReflectionException $e) { - throw InvalidPrefetchMethodRuntimeException::methodNotFound($reflector, $refClass, $prefetchMethodName, $e); + throw InvalidPrefetchMethodRuntimeException::methodNotFound( + $reflector, + $refClass, + $prefetchMethodName, + $e, + ); } $prefetchParameters = $prefetchRefMethod->getParameters(); @@ -846,37 +939,47 @@ private function getPrefetchParameter( * * @throws AnnotationException */ - private function getInputFieldsByMethodAnnotations(string|object $controller, ReflectionClass $refClass, ReflectionMethod $refMethod, string $annotationName, bool $injectSource, array $defaultProperties, string|null $typeName = null, bool $isUpdate = false): array + private function getInputFieldsByMethodAnnotations( + string|object $controller, + ReflectionClass $refClass, + ReflectionMethod $refMethod, + string $annotationName, + bool $injectSource, + array $defaultProperties, + string|null $typeName = null, + bool $isUpdate = false, + ): array { $fields = []; $annotations = $this->annotationReader->getMethodAnnotations($refMethod, $annotationName); foreach ($annotations as $fieldAnnotations) { $description = null; - if (!($fieldAnnotations instanceof Field)) { + if (! ($fieldAnnotations instanceof Field)) { continue; } $for = $fieldAnnotations->getFor(); - if ($typeName && $for && !in_array($typeName, $for)) { + if ($typeName && $for && ! in_array($typeName, $for)) { continue; } - $description = $fieldAnnotations->getDescription(); $docBlockObj = $this->cachedDocBlockFactory->getDocBlock($refMethod); $methodName = $refMethod->getName(); - if (!str_starts_with($methodName, 'set')) { + if (! str_starts_with($methodName, 'set')) { continue; } + $name = $fieldAnnotations->getName() ?: $this->namingStrategy->getInputFieldNameFromMethodName($methodName); - if (!$description) { + $description = $fieldAnnotations->getDescription(); + if (! $description) { $description = $docBlockObj->getSummary() . "\n" . $docBlockObj->getDescription()->render(); } $parameters = $refMethod->getParameters(); if ($injectSource === true) { - $firstParameter = array_shift($parameters); // TODO: check that $first_parameter type is correct. + $firstParameter = array_shift($parameters); } /** @var array $args */ @@ -912,7 +1015,7 @@ private function getInputFieldsByMethodAnnotations(string|object $controller, Re middlewareAnnotations: $this->annotationReader->getMiddlewareAnnotations($refMethod), isUpdate: $isUpdate, hasDefaultValue: $isUpdate, - defaultValue: $args[$name]->getDefaultValue() + defaultValue: $args[$name]->getDefaultValue(), ); $field = $this->inputFieldMiddleware->process($inputFieldDescriptor, new class implements InputFieldHandlerInterface { @@ -942,7 +1045,15 @@ public function handle(InputFieldDescriptor $inputFieldDescriptor): InputField|n * * @throws AnnotationException */ - private function getInputFieldsByPropertyAnnotations(string|object $controller, ReflectionClass $refClass, ReflectionProperty $refProperty, string $annotationName, array $defaultProperties, string|null $typeName = null, bool $isUpdate = false): array + private function getInputFieldsByPropertyAnnotations( + string|object $controller, + ReflectionClass $refClass, + ReflectionProperty $refProperty, + string $annotationName, + array $defaultProperties, + string|null $typeName = null, + bool $isUpdate = false, + ): array { $fields = []; @@ -951,12 +1062,12 @@ private function getInputFieldsByPropertyAnnotations(string|object $controller, foreach ($annotations as $annotation) { $description = null; - if (!($annotation instanceof Field)) { + if (! ($annotation instanceof Field)) { continue; } $for = $annotation->getFor(); - if ($typeName && $for && !in_array($typeName, $for)) { + if ($typeName && $for && ! in_array($typeName, $for)) { continue; } @@ -966,19 +1077,22 @@ private function getInputFieldsByPropertyAnnotations(string|object $controller, $constructerParameters = $this->getClassConstructParameterNames($refClass); $inputProperty = $this->typeMapper->mapInputProperty($refProperty, $docBlock, $name, $inputType, $defaultProperties[$refProperty->getName()] ?? null, $isUpdate ? true : null); - if (!$description) { + if (! $description) { $description = $inputProperty->getDescription(); } $type = $inputProperty->getType(); - if (!$inputType && $isUpdate && $type instanceof NonNull) { + if (! $inputType && $isUpdate && $type instanceof NonNull) { $type = $type->getWrappedType(); } assert($type instanceof InputType); $forConstructorHydration = in_array($name, $constructerParameters); - $resolver = $forConstructorHydration ? - new SourceConstructorParameterResolver($refProperty->getDeclaringClass()->getName(), $refProperty->getName()) : - new SourceInputPropertyResolver($refProperty); + $resolver = $forConstructorHydration + ? new SourceConstructorParameterResolver( + $refProperty->getDeclaringClass()->getName(), + $refProperty->getName(), + ) + : new SourceInputPropertyResolver($refProperty); // setters and properties $inputFieldDescriptor = new InputFieldDescriptor( @@ -1018,7 +1132,7 @@ private function getClassConstructParameterNames(ReflectionClass $refClass): arr { $constructor = $refClass->getConstructor(); - if (!$constructor) { + if (! $constructor) { return []; } diff --git a/src/GlobControllerQueryProvider.php b/src/GlobControllerQueryProvider.php index 208a7b5bfb..85998c780b 100644 --- a/src/GlobControllerQueryProvider.php +++ b/src/GlobControllerQueryProvider.php @@ -4,10 +4,6 @@ namespace TheCodingMachine\GraphQLite; -use function class_exists; -use function interface_exists; -use function is_array; -use function str_replace; use GraphQL\Type\Definition\FieldDefinition; use InvalidArgumentException; use Mouf\Composer\ClassNameMapper; @@ -22,6 +18,11 @@ use TheCodingMachine\GraphQLite\Annotations\Query; use TheCodingMachine\GraphQLite\Annotations\Subscription; +use function class_exists; +use function interface_exists; +use function is_array; +use function str_replace; + /** * Scans all the classes in a given namespace of the main project (not the vendor directory). * Analyzes all classes and detects "Query", "Mutation", and "Subscription" annotations. diff --git a/src/InputField.php b/src/InputField.php index f2778c47c7..c290175b35 100644 --- a/src/InputField.php +++ b/src/InputField.php @@ -49,7 +49,7 @@ public function __construct( bool $isUpdate, bool $hasDefaultValue, mixed $defaultValue, - array|null $additionalConfig = null + array|null $additionalConfig = null, ) { $config = [ 'name' => $name, @@ -63,9 +63,7 @@ public function __construct( $this->resolve = function (object|null $source, array $args, $context, ResolveInfo $info) use ($arguments, $originalResolver, $resolver) { if ($this->forConstructorHydration) { - $toPassArgs = [ - $arguments[$this->name]->resolve($source, $args, $context, $info) - ]; + $toPassArgs = [$arguments[$this->name]->resolve($source, $args, $context, $info)]; } else { $toPassArgs = $this->paramsToArguments($arguments, $source, $args, $context, $info, $resolver); } diff --git a/src/InputFieldDescriptor.php b/src/InputFieldDescriptor.php index 5dd436994d..d59b19cb89 100644 --- a/src/InputFieldDescriptor.php +++ b/src/InputFieldDescriptor.php @@ -6,22 +6,14 @@ use GraphQL\Type\Definition\InputType; use GraphQL\Type\Definition\Type; -use ReflectionMethod; -use ReflectionProperty; use TheCodingMachine\GraphQLite\Annotations\MiddlewareAnnotations; -use TheCodingMachine\GraphQLite\Middlewares\MagicPropertyResolver; -use TheCodingMachine\GraphQLite\Middlewares\ResolverInterface; use TheCodingMachine\GraphQLite\Middlewares\ServiceResolver; use TheCodingMachine\GraphQLite\Middlewares\SourceConstructorParameterResolver; use TheCodingMachine\GraphQLite\Middlewares\SourceInputPropertyResolver; use TheCodingMachine\GraphQLite\Middlewares\SourceMethodResolver; -use TheCodingMachine\GraphQLite\Middlewares\SourcePropertyResolver; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; use TheCodingMachine\GraphQLite\Utils\Cloneable; -use function assert; -use function is_callable; - /** * A class that describes a field to be created. * To contains getters and setters to alter the field behaviour. diff --git a/src/InputTypeUtils.php b/src/InputTypeUtils.php index e99363c0e3..3e4ad37fbb 100644 --- a/src/InputTypeUtils.php +++ b/src/InputTypeUtils.php @@ -18,7 +18,6 @@ use ReflectionNamedType; use RuntimeException; use TheCodingMachine\GraphQLite\Parameters\ExpandsInputTypeParameters; -use TheCodingMachine\GraphQLite\Parameters\InputTypeParameter; use TheCodingMachine\GraphQLite\Parameters\InputTypeParameterInterface; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; diff --git a/src/Mappers/AbstractTypeMapper.php b/src/Mappers/AbstractTypeMapper.php index 37df90ea2e..db3946d532 100644 --- a/src/Mappers/AbstractTypeMapper.php +++ b/src/Mappers/AbstractTypeMapper.php @@ -30,7 +30,6 @@ use TheCodingMachine\GraphQLite\Types\MutableObjectType; use TheCodingMachine\GraphQLite\Types\ResolvableMutableInputInterface; -use UnitEnum; use function assert; /** @@ -70,8 +69,20 @@ public function __construct( ) { $this->cacheContract = new Psr16Adapter($this->cache, $cachePrefix, $this->globTTL ?? 0); - $this->mapClassToAnnotationsCache = new ClassBoundCacheContract(new ClassBoundMemoryAdapter(new ClassBoundCache(new FileBoundCache($this->cache, 'classToAnnotations_' . $cachePrefix)))); - $this->mapClassToExtendAnnotationsCache = new ClassBoundCacheContract(new ClassBoundMemoryAdapter(new ClassBoundCache(new FileBoundCache($this->cache, 'classToExtendAnnotations_' . $cachePrefix)))); + + $classToAnnotationsCache = new ClassBoundCache( + new FileBoundCache($this->cache, 'classToAnnotations_' . $cachePrefix), + ); + $this->mapClassToAnnotationsCache = new ClassBoundCacheContract( + new ClassBoundMemoryAdapter($classToAnnotationsCache), + ); + + $classToExtendedAnnotationsCache = new ClassBoundCache( + new FileBoundCache($this->cache, 'classToExtendAnnotations_' . $cachePrefix), + ); + $this->mapClassToExtendAnnotationsCache = new ClassBoundCacheContract( + new ClassBoundMemoryAdapter($classToExtendedAnnotationsCache), + ); } /** diff --git a/src/Mappers/GlobAnnotationsCache.php b/src/Mappers/GlobAnnotationsCache.php index 426a91cbe2..dd41c665e4 100644 --- a/src/Mappers/GlobAnnotationsCache.php +++ b/src/Mappers/GlobAnnotationsCache.php @@ -18,9 +18,13 @@ final class GlobAnnotationsCache /** * @param class-string|null $typeClassName - * @param array|null, 2:bool, 3:class-string}> $factories An array mapping a factory method name to an input name / class name / default flag / declaring class - * @param array}> $decorators An array mapping a decorator method name to an input name / declaring class - * @param array, 1: bool, 2: string|null, 3: bool}> $inputs An array mapping an input type name to an input name / declaring class + * @param array|null, 2:bool, 3:class-string}> $factories + * An array mapping a factory method name to an input name / class name / default flag / + * declaring class + * @param array}> $decorators + * An array mapping a decorator method name to an input name / declaring class + * @param array, 1: bool, 2: string|null, 3: bool}> $inputs + * An array mapping an input type name to an input name / declaring class */ public function __construct( private readonly string|null $typeClassName = null, @@ -67,6 +71,7 @@ public function registerFactory(string $methodName, string $inputName, string|nu return $this->with( factories: [ ...$this->factories, + // phpcs:ignore $methodName => [$inputName, $className, $isDefault, $declaringClass], ], ); @@ -78,6 +83,7 @@ public function registerDecorator(string $methodName, string $inputName, string return $this->with( decorators: [ ...$this->decorators, + // phpcs:ignore $methodName => [$inputName, $declaringClass], ], ); @@ -105,6 +111,7 @@ public function registerInput(string $name, string $className, Input $input): se return $this->with( inputs: [ ...$this->inputs, + // phpcs:ignore $name => [$className, $input->isDefault(), $input->getDescription(), $input->isUpdate()], ], ); diff --git a/src/Mappers/GlobTypeMapper.php b/src/Mappers/GlobTypeMapper.php index e8dc017381..132673b898 100644 --- a/src/Mappers/GlobTypeMapper.php +++ b/src/Mappers/GlobTypeMapper.php @@ -26,11 +26,44 @@ */ final class GlobTypeMapper extends AbstractTypeMapper { - /** @param NS $namespace The namespace that contains the GraphQL types (they must have a `@Type` annotation) */ - public function __construct(private NS $namespace, TypeGenerator $typeGenerator, InputTypeGenerator $inputTypeGenerator, InputTypeUtils $inputTypeUtils, ContainerInterface $container, AnnotationReader $annotationReader, NamingStrategyInterface $namingStrategy, RecursiveTypeMapperInterface $recursiveTypeMapper, CacheInterface $cache, int|null $globTTL = 2, int|null $mapTTL = null) - { - $cachePrefix = str_replace(['\\', '{', '}', '(', ')', '/', '@', ':'], '_', $namespace->getNamespace()); - parent::__construct($cachePrefix, $typeGenerator, $inputTypeGenerator, $inputTypeUtils, $container, $annotationReader, $namingStrategy, $recursiveTypeMapper, $cache, $globTTL, $mapTTL); + /** + * Constructor + * + * @param NS $namespace The namespace that contains the GraphQL types + * (they must have a `@Type` annotation) + */ + public function __construct( + private NS $namespace, + TypeGenerator $typeGenerator, + InputTypeGenerator $inputTypeGenerator, + InputTypeUtils $inputTypeUtils, + ContainerInterface $container, + AnnotationReader $annotationReader, + NamingStrategyInterface $namingStrategy, + RecursiveTypeMapperInterface $recursiveTypeMapper, + CacheInterface $cache, + int|null $globTTL = 2, + int|null $mapTTL = null, + ) { + $cachePrefix = str_replace( + ['\\', '{', '}', '(', ')', '/', '@', ':'], + '_', + $namespace->getNamespace(), + ); + + parent::__construct( + $cachePrefix, + $typeGenerator, + $inputTypeGenerator, + $inputTypeUtils, + $container, + $annotationReader, + $namingStrategy, + $recursiveTypeMapper, + $cache, + $globTTL, + $mapTTL, + ); } /** diff --git a/src/Mappers/Root/EnumTypeMapper.php b/src/Mappers/Root/EnumTypeMapper.php index 2be7329b0d..89a7e041e9 100644 --- a/src/Mappers/Root/EnumTypeMapper.php +++ b/src/Mappers/Root/EnumTypeMapper.php @@ -25,6 +25,7 @@ use function assert; use function enum_exists; +use function ltrim; /** * Maps an enum class to a GraphQL type (only available in PHP>=8.1) @@ -129,25 +130,29 @@ private function mapByClassName(string $enumClass): EnumType|null $enumDescription = $docBlock->getSummary(); } + /** @var array $enumCaseDescriptions */ $enumCaseDescriptions = []; + /** @var array $enumCaseDeprecationReasons */ $enumCaseDeprecationReasons = []; + foreach ($reflectionEnum->getCases() as $reflectionEnumCase) { $docComment = $reflectionEnumCase->getDocComment(); - if ($docComment) { - $docBlock = $docBlockFactory->create($docComment); - $enumCaseDescription = $docBlock->getSummary(); + if (! $docComment) { + continue; + } - $enumCaseDescriptions[$reflectionEnumCase->getName()] = $enumCaseDescription; - $deprecation = $docBlock->getTagsByName('deprecated')[0] ?? null; + $docBlock = $docBlockFactory->create($docComment); + $enumCaseDescription = $docBlock->getSummary(); - if ($deprecation) { - $enumCaseDeprecationReasons[$reflectionEnumCase->getName()] = (string) $deprecation; - } + $enumCaseDescriptions[$reflectionEnumCase->getName()] = $enumCaseDescription; + $deprecation = $docBlock->getTagsByName('deprecated')[0] ?? null; + + // phpcs:ignore + if ($deprecation) { + $enumCaseDeprecationReasons[$reflectionEnumCase->getName()] = (string) $deprecation; } } - /** @var array $enumCaseDescriptions */ - /** @var array $enumCaseDeprecationReasons */ $type = new EnumType($enumClass, $typeName, $enumDescription, $enumCaseDescriptions, $enumCaseDeprecationReasons, $useValues); return $this->cacheByName[$type->name] = $this->cache[$enumClass] = $type; diff --git a/src/Mappers/Root/MyCLabsEnumTypeMapper.php b/src/Mappers/Root/MyCLabsEnumTypeMapper.php index c0c35aa168..7a2d7eae2c 100644 --- a/src/Mappers/Root/MyCLabsEnumTypeMapper.php +++ b/src/Mappers/Root/MyCLabsEnumTypeMapper.php @@ -66,7 +66,13 @@ public function toGraphQLInputType( ): InputType&\GraphQL\Type\Definition\Type { $result = $this->map($type); - return $result ?? $this->next->toGraphQLInputType($type, $subType, $argumentName, $reflector, $docBlockObj); + return $result ?? $this->next->toGraphQLInputType( + $type, + $subType, + $argumentName, + $reflector, + $docBlockObj, + ); } private function map(Type $type): EnumType|null @@ -101,7 +107,6 @@ private function mapByClassName(string $enumClass): EnumType|null return $this->cacheByName[$type->name] = $this->cache[$enumClass] = $type; } - private function getTypeName(ReflectionClass $refClass): string { $enumType = $this->annotationReader->getEnumTypeAnnotation($refClass); @@ -116,8 +121,8 @@ private function getTypeName(ReflectionClass $refClass): string /** * Returns a GraphQL type by name. - * If this root type mapper can return this type in "toGraphQLOutputType" or "toGraphQLInputType", it should - * also map these types by name in the "mapNameToType" method. + * If this root type mapper can return this type in "toGraphQLOutputType" or "toGraphQLInputType", + * it should also map these types by name in the "mapNameToType" method. * * @param string $typeName The name of the GraphQL type */ @@ -125,7 +130,8 @@ public function mapNameToType(string $typeName): NamedType&\GraphQL\Type\Definit { // This is a hack to make sure "$schema->assertValid()" returns true. // The mapNameToType will fail if the mapByClassName method was not called before. - // This is actually not an issue in real life scenarios where enum types are never queried by type name. + // This is actually not an issue in real life scenarios where enum types are never queried + // by type name. if (isset($this->cacheByName[$typeName])) { return $this->cacheByName[$typeName]; } @@ -138,15 +144,6 @@ public function mapNameToType(string $typeName): NamedType&\GraphQL\Type\Definit return $type; } - /*if (strpos($typeName, 'MyCLabsEnum_') === 0) { - $className = str_replace('__', '\\', substr($typeName, 12)); - - $type = $this->mapByClassName($className); - if ($type !== null) { - return $type; - } - }*/ - return $this->next->mapNameToType($typeName); } @@ -161,15 +158,16 @@ private function getNameToClassMapping(): array $this->nameToClassMapping = $this->cacheService->get('myclabsenum_name_to_class', function () { $nameToClassMapping = []; foreach ($this->namespaces as $ns) { + /** @var class-string $className */ foreach ($ns->getClassList() as $className => $classRef) { if (! $classRef->isSubclassOf(Enum::class)) { continue; } - /** @var class-string $className */ $nameToClassMapping[$this->getTypeName($classRef)] = $className; } } + return $nameToClassMapping; }); } diff --git a/src/Mappers/Root/RootTypeMapperFactoryContext.php b/src/Mappers/Root/RootTypeMapperFactoryContext.php index 1206f64abb..a5e1c1cb11 100644 --- a/src/Mappers/Root/RootTypeMapperFactoryContext.php +++ b/src/Mappers/Root/RootTypeMapperFactoryContext.php @@ -20,6 +20,8 @@ final class RootTypeMapperFactoryContext { /** + * Constructor + * * @param iterable $typeNamespaces */ public function __construct( diff --git a/src/Mappers/StaticClassListTypeMapper.php b/src/Mappers/StaticClassListTypeMapper.php index 8eeec45a9c..eec76c3097 100644 --- a/src/Mappers/StaticClassListTypeMapper.php +++ b/src/Mappers/StaticClassListTypeMapper.php @@ -33,10 +33,38 @@ final class StaticClassListTypeMapper extends AbstractTypeMapper private array|null $classes = null; /** @param array $classList The list of classes to analyze. */ - public function __construct(private array $classList, TypeGenerator $typeGenerator, InputTypeGenerator $inputTypeGenerator, InputTypeUtils $inputTypeUtils, ContainerInterface $container, AnnotationReader $annotationReader, NamingStrategyInterface $namingStrategy, RecursiveTypeMapperInterface $recursiveTypeMapper, CacheInterface $cache, int|null $globTTL = 2, int|null $mapTTL = null) - { - $cachePrefix = str_replace(['\\', '{', '}', '(', ')', '/', '@', ':'], '_', implode('_', $classList)); - parent::__construct($cachePrefix, $typeGenerator, $inputTypeGenerator, $inputTypeUtils, $container, $annotationReader, $namingStrategy, $recursiveTypeMapper, $cache, $globTTL, $mapTTL); + public function __construct( + private array $classList, + TypeGenerator $typeGenerator, + InputTypeGenerator $inputTypeGenerator, + InputTypeUtils $inputTypeUtils, + ContainerInterface $container, + AnnotationReader $annotationReader, + NamingStrategyInterface $namingStrategy, + RecursiveTypeMapperInterface $recursiveTypeMapper, + CacheInterface $cache, + int|null $globTTL = 2, + int|null $mapTTL = null, + ) { + $cachePrefix = str_replace( + ['\\', '{', '}', '(', ')', '/', '@', ':'], + '_', + implode('_', $classList), + ); + + parent::__construct( + $cachePrefix, + $typeGenerator, + $inputTypeGenerator, + $inputTypeUtils, + $container, + $annotationReader, + $namingStrategy, + $recursiveTypeMapper, + $cache, + $globTTL, + $mapTTL, + ); } /** diff --git a/src/Middlewares/AuthorizationInputFieldMiddleware.php b/src/Middlewares/AuthorizationInputFieldMiddleware.php index 670bd973d6..e63ea4dba7 100644 --- a/src/Middlewares/AuthorizationInputFieldMiddleware.php +++ b/src/Middlewares/AuthorizationInputFieldMiddleware.php @@ -12,8 +12,6 @@ use TheCodingMachine\GraphQLite\Security\AuthenticationServiceInterface; use TheCodingMachine\GraphQLite\Security\AuthorizationServiceInterface; -use function assert; - /** * Middleware in charge of managing "Logged" and "Right" annotations. */ diff --git a/src/Middlewares/MagicPropertyResolver.php b/src/Middlewares/MagicPropertyResolver.php index 2338442396..daa3f796f7 100644 --- a/src/Middlewares/MagicPropertyResolver.php +++ b/src/Middlewares/MagicPropertyResolver.php @@ -9,13 +9,16 @@ use function method_exists; /** - * Resolves field by getting the value of $propertyName from the source object through magic getter __get. + * Resolves field by getting the value of $propertyName from the source object through + * magic getter __get. * * @internal */ final class MagicPropertyResolver implements ResolverInterface { /** + * Constructor + * * @param class-string $className */ public function __construct( @@ -24,9 +27,7 @@ public function __construct( ) { } - /** - * @return class-string - */ + /** @return class-string */ public function className(): string { return $this->className; diff --git a/src/Middlewares/SourceConstructorParameterResolver.php b/src/Middlewares/SourceConstructorParameterResolver.php index 4611b423d8..cfe92df6a6 100644 --- a/src/Middlewares/SourceConstructorParameterResolver.php +++ b/src/Middlewares/SourceConstructorParameterResolver.php @@ -1,8 +1,8 @@ className; @@ -47,4 +47,4 @@ public function toString(): string { return $this->className . '::__construct($' . $this->parameterName . ')'; } -} \ No newline at end of file +} diff --git a/src/Middlewares/SourceMethodResolver.php b/src/Middlewares/SourceMethodResolver.php index 6fc862fe7a..50141ad682 100644 --- a/src/Middlewares/SourceMethodResolver.php +++ b/src/Middlewares/SourceMethodResolver.php @@ -4,6 +4,7 @@ namespace TheCodingMachine\GraphQLite\Middlewares; +use ReflectionMethod; use TheCodingMachine\GraphQLite\GraphQLRuntimeException; use function assert; @@ -17,12 +18,12 @@ final class SourceMethodResolver implements ResolverInterface { public function __construct( - private readonly \ReflectionMethod $methodReflection, + private readonly ReflectionMethod $methodReflection, ) { } - public function methodReflection(): \ReflectionMethod + public function methodReflection(): ReflectionMethod { return $this->methodReflection; } diff --git a/src/QueryFieldDescriptor.php b/src/QueryFieldDescriptor.php index 13bdc491f0..aab5f70c6f 100644 --- a/src/QueryFieldDescriptor.php +++ b/src/QueryFieldDescriptor.php @@ -6,21 +6,14 @@ use GraphQL\Type\Definition\OutputType; use GraphQL\Type\Definition\Type; -use ReflectionMethod; -use ReflectionProperty; use TheCodingMachine\GraphQLite\Annotations\MiddlewareAnnotations; use TheCodingMachine\GraphQLite\Middlewares\MagicPropertyResolver; -use TheCodingMachine\GraphQLite\Middlewares\ResolverInterface; use TheCodingMachine\GraphQLite\Middlewares\ServiceResolver; -use TheCodingMachine\GraphQLite\Middlewares\SourceInputPropertyResolver; use TheCodingMachine\GraphQLite\Middlewares\SourceMethodResolver; use TheCodingMachine\GraphQLite\Middlewares\SourcePropertyResolver; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; use TheCodingMachine\GraphQLite\Utils\Cloneable; -use function assert; -use function is_array; - /** * A class that describes a field to be created. * To contains getters and setters to alter the field behaviour. diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index 28fb46fdaf..8f23bf5df6 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -59,12 +59,10 @@ use TheCodingMachine\GraphQLite\Types\TypeResolver; use TheCodingMachine\GraphQLite\Utils\NamespacedCache; use TheCodingMachine\GraphQLite\Utils\Namespaces\NamespaceFactory; -use UnitEnum; use function array_map; use function array_reverse; use function class_exists; -use function interface_exists; use function md5; use function substr; diff --git a/src/Types/EnumType.php b/src/Types/EnumType.php index 260d40fa38..9e48f98d22 100644 --- a/src/Types/EnumType.php +++ b/src/Types/EnumType.php @@ -25,7 +25,7 @@ class EnumType extends BaseEnumType public function __construct( string $enumName, string $typeName, - ?string $description, + string|null $description, array $caseDescriptions, array $caseDeprecationReasons, private readonly bool $useValues = false, @@ -41,13 +41,11 @@ public function __construct( ]; } - parent::__construct( - [ - 'name' => $typeName, - 'values' => $typeValues, - 'description' => $description, - ] - ); + parent::__construct([ + 'name' => $typeName, + 'values' => $typeValues, + 'description' => $description, + ]); } // phpcs:disable SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint diff --git a/src/Types/MutableInterfaceType.php b/src/Types/MutableInterfaceType.php index 11ba17b567..7e637c8824 100644 --- a/src/Types/MutableInterfaceType.php +++ b/src/Types/MutableInterfaceType.php @@ -24,6 +24,7 @@ public function __construct(array $config, string|null $className = null) $this->status = self::STATUS_PENDING; parent::__construct($config); + $this->className = $className; } } diff --git a/src/Types/MutableObjectType.php b/src/Types/MutableObjectType.php index a0209eccd0..ac91ba9c0d 100644 --- a/src/Types/MutableObjectType.php +++ b/src/Types/MutableObjectType.php @@ -24,6 +24,7 @@ public function __construct(array $config, string|null $className = null) $this->status = self::STATUS_PENDING; parent::__construct($config); + $this->className = $className; } } diff --git a/src/Types/ResolvableMutableInputObjectType.php b/src/Types/ResolvableMutableInputObjectType.php index 2f7eb71ede..056f04174d 100644 --- a/src/Types/ResolvableMutableInputObjectType.php +++ b/src/Types/ResolvableMutableInputObjectType.php @@ -53,7 +53,7 @@ public function __construct(string $name, private FieldsBuilder $fieldsBuilder, { $resolve = [$factory, $methodName]; assert(is_callable($resolve)); - $this->resolve = $resolve; + $this->resolve = $resolve; /** * @return iterable @@ -71,7 +71,9 @@ public function __construct(string $name, private FieldsBuilder $fieldsBuilder, if ($comment) { $config['description'] = $comment; } + $config += $additionalConfig; + parent::__construct($config); } @@ -83,7 +85,7 @@ public function __construct(string $name, private FieldsBuilder $fieldsBuilder, private function getParameters(): array { if ($this->parameters === null) { - $method = new ReflectionMethod($this->resolve[0], $this->resolve[1]); + $method = new ReflectionMethod($this->resolve[0], $this->resolve[1]); $this->parameters = $this->fieldsBuilder->getParameters($method); } @@ -94,7 +96,7 @@ private function getParameters(): array private function getParametersForDecorator(int $key): array { if (! isset($this->decoratorsParameters[$key])) { - $method = new ReflectionMethod($this->decorators[$key][0], $this->decorators[$key][1]); + $method = new ReflectionMethod($this->decorators[$key][0], $this->decorators[$key][1]); $this->decoratorsParameters[$key] = $this->fieldsBuilder->getParametersForDecorator($method); } diff --git a/src/Utils/Namespaces/NS.php b/src/Utils/Namespaces/NS.php index 21766b6757..4e6fe98d99 100644 --- a/src/Utils/Namespaces/NS.php +++ b/src/Utils/Namespaces/NS.php @@ -8,7 +8,6 @@ use Psr\SimpleCache\CacheInterface; use ReflectionClass; use TheCodingMachine\ClassExplorer\Glob\GlobClassExplorer; -use UnitEnum; use function class_exists; use function interface_exists; From 29a6663883f7cd560df4ed5caa5ac98d8220ccd1 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Sun, 11 Feb 2024 19:50:15 -0500 Subject: [PATCH 053/108] Limit codecov for only 8.3 --- .github/workflows/continuous_integration.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 224ffac683..c65c34ea25 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -87,5 +87,5 @@ jobs: - uses: codecov/codecov-action@v4.0.1 # upload the coverage to codecov with: fail_ci_if_error: true # optional (default = false) - # Do not upload in forks, and only on php8, latest deps - if: ${{ github.repository == 'thecodingmachine/graphqlite' && matrix.install-args == '' }} + # Do not upload in forks, and only on php8.3, latest deps + if: ${{ github.repository == 'thecodingmachine/graphqlite' && matrix.php-version == '8.3' && matrix.install-args == '' }} From 9898bd93d55e165ac66a6b3c92415c75be0fbf53 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Sun, 11 Feb 2024 20:04:35 -0500 Subject: [PATCH 054/108] Don't fail for Codecov until token is updated --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index c65c34ea25..9a617c5f7d 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -86,6 +86,6 @@ jobs: - uses: codecov/codecov-action@v4.0.1 # upload the coverage to codecov with: - fail_ci_if_error: true # optional (default = false) + fail_ci_if_error: false # optional (default = false) - Need CODECOV_TOKEN # Do not upload in forks, and only on php8.3, latest deps if: ${{ github.repository == 'thecodingmachine/graphqlite' && matrix.php-version == '8.3' && matrix.install-args == '' }} From 758d02de674fb2ee5bbaec6add95b91db9ae0dba Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Sun, 10 Mar 2024 22:47:52 -0400 Subject: [PATCH 055/108] Added contribution details to README --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index d71eaa7600..46df4f459a 100644 --- a/README.md +++ b/README.md @@ -86,3 +86,9 @@ That's it, you're good to go :tada: mutate away! ``` Want to learn more? Head to the [documentation](https://graphqlite.thecodingmachine.io/)! + +## Contributing + +Contributions are welcomed via pull requests. If you'd like to discuss prior to submitting a PR, consider a [discussion](https://github.com/thecodingmachine/graphqlite/discussions). If it's a bug/issue, you can [submit an issue](https://github.com/thecodingmachine/graphqlite/issues) first. + +All PRs should have sufficient test coverage for any additions or changes. PRs will not be merged without these. From 256da311c38bdffb420e165e9ad7cc8414342824 Mon Sep 17 00:00:00 2001 From: Olexandr Grynchuk Date: Tue, 12 Mar 2024 00:06:06 +0200 Subject: [PATCH 056/108] Fix prefetching the same GQL type for batch request (#658) * Fix prefetching the same GQL type for batch request * Fix phpstan errors * Fix after pull master and resolving conflicts * Fix cs * Fix phpstan * Bring back ResolveInfo|null instead of ?ResolveInfo * fix cs --- src/Parameters/PrefetchDataParameter.php | 10 +-- src/PrefetchBuffer.php | 66 ++++++++++++------ .../Controllers/CompanyController.php | 17 +++++ .../Controllers/ContactController.php | 10 +++ tests/Fixtures/Integration/Models/Company.php | 16 +++++ .../Integration/Types/CompanyType.php | 42 ++++++++++++ .../Integration/Types/ContactType.php | 53 +++++++++++++++ tests/Fixtures/Integration/Types/PostType.php | 25 +++++++ tests/Integration/EndToEndTest.php | 68 +++++++++++++++++++ tests/SchemaFactoryTest.php | 8 +++ 10 files changed, 290 insertions(+), 25 deletions(-) create mode 100644 tests/Fixtures/Integration/Controllers/CompanyController.php create mode 100644 tests/Fixtures/Integration/Models/Company.php create mode 100644 tests/Fixtures/Integration/Types/CompanyType.php create mode 100644 tests/Fixtures/Integration/Types/PostType.php diff --git a/src/Parameters/PrefetchDataParameter.php b/src/Parameters/PrefetchDataParameter.php index fe81e4aceb..4b233c5e3c 100644 --- a/src/Parameters/PrefetchDataParameter.php +++ b/src/Parameters/PrefetchDataParameter.php @@ -43,7 +43,7 @@ public function resolve(object|null $source, array $args, mixed $context, Resolv } $prefetchBuffer = $context->getPrefetchBuffer($this); - $prefetchBuffer->register($source, $args); + $prefetchBuffer->register($source, $args, $info); // The way this works is simple: GraphQL first iterates over every requested field and calls ->resolve() // on it. That, in turn, calls this method. GraphQL doesn't need the actual value just yet; it simply @@ -53,20 +53,20 @@ public function resolve(object|null $source, array $args, mixed $context, Resolv // needed, GraphQL calls the callback of Deferred below. That's when we call the prefetch method, // already knowing all the requested fields (source-arguments combinations). return new Deferred(function () use ($info, $context, $args, $prefetchBuffer) { - if (! $prefetchBuffer->hasResult($args)) { + if (! $prefetchBuffer->hasResult($args, $info)) { $prefetchResult = $this->computePrefetch($args, $context, $info, $prefetchBuffer); - $prefetchBuffer->storeResult($prefetchResult, $args); + $prefetchBuffer->storeResult($prefetchResult, $args, $info); } - return $prefetchResult ?? $prefetchBuffer->getResult($args); + return $prefetchResult ?? $prefetchBuffer->getResult($args, $info); }); } /** @param array $args */ private function computePrefetch(array $args, mixed $context, ResolveInfo $info, PrefetchBuffer $prefetchBuffer): mixed { - $sources = $prefetchBuffer->getObjectsByArguments($args); + $sources = $prefetchBuffer->getObjectsByArguments($args, $info); $toPassPrefetchArgs = QueryField::paramsToArguments($this->fieldName, $this->parameters, null, $args, $context, $info, $this->resolver); return ($this->resolver)($sources, ...$toPassPrefetchArgs); diff --git a/src/PrefetchBuffer.php b/src/PrefetchBuffer.php index 70cc588af0..8b1afb02a0 100644 --- a/src/PrefetchBuffer.php +++ b/src/PrefetchBuffer.php @@ -4,6 +4,8 @@ namespace TheCodingMachine\GraphQLite; +use GraphQL\Type\Definition\ResolveInfo; + use function array_key_exists; use function md5; use function serialize; @@ -20,14 +22,27 @@ class PrefetchBuffer private array $results = []; /** @param array $arguments The input arguments passed from GraphQL to the field. */ - public function register(object $object, array $arguments): void - { - $this->objects[$this->computeHash($arguments)][] = $object; + public function register( + object $object, + array $arguments, + ResolveInfo|null $info = null, + ): void { + $this->objects[$this->computeHash($arguments, $info)][] = $object; } /** @param array $arguments The input arguments passed from GraphQL to the field. */ - private function computeHash(array $arguments): string - { + private function computeHash( + array $arguments, + ResolveInfo|null $info, + ): string { + if ( + $info instanceof ResolveInfo + && isset($info->operation) + && $info->operation->loc?->source?->body !== null + ) { + return md5(serialize($arguments) . $info->operation->loc->source->body); + } + return md5(serialize($arguments)); } @@ -36,32 +51,43 @@ private function computeHash(array $arguments): string * * @return array */ - public function getObjectsByArguments(array $arguments): array - { - return $this->objects[$this->computeHash($arguments)] ?? []; + public function getObjectsByArguments( + array $arguments, + ResolveInfo|null $info = null, + ): array { + return $this->objects[$this->computeHash($arguments, $info)] ?? []; } /** @param array $arguments The input arguments passed from GraphQL to the field. */ - public function purge(array $arguments): void - { - unset($this->objects[$this->computeHash($arguments)]); + public function purge( + array $arguments, + ResolveInfo|null $info = null, + ): void { + unset($this->objects[$this->computeHash($arguments, $info)]); } /** @param array $arguments The input arguments passed from GraphQL to the field. */ - public function storeResult(mixed $result, array $arguments): void - { - $this->results[$this->computeHash($arguments)] = $result; + public function storeResult( + mixed $result, + array $arguments, + ResolveInfo|null $info = null, + ): void { + $this->results[$this->computeHash($arguments, $info)] = $result; } /** @param array $arguments The input arguments passed from GraphQL to the field. */ - public function hasResult(array $arguments): bool - { - return array_key_exists($this->computeHash($arguments), $this->results); + public function hasResult( + array $arguments, + ResolveInfo|null $info = null, + ): bool { + return array_key_exists($this->computeHash($arguments, $info), $this->results); } /** @param array $arguments The input arguments passed from GraphQL to the field. */ - public function getResult(array $arguments): mixed - { - return $this->results[$this->computeHash($arguments)]; + public function getResult( + array $arguments, + ResolveInfo|null $info = null, + ): mixed { + return $this->results[$this->computeHash($arguments, $info)]; } } diff --git a/tests/Fixtures/Integration/Controllers/CompanyController.php b/tests/Fixtures/Integration/Controllers/CompanyController.php new file mode 100644 index 0000000000..ccb65bc384 --- /dev/null +++ b/tests/Fixtures/Integration/Controllers/CompanyController.php @@ -0,0 +1,17 @@ + new Contact('Joe'), + 'Bill' => new Contact('Bill'), + default => null, + }; + } + #[Mutation] public function saveContact(Contact $contact): Contact { diff --git a/tests/Fixtures/Integration/Models/Company.php b/tests/Fixtures/Integration/Models/Company.php new file mode 100644 index 0000000000..75c1e7467f --- /dev/null +++ b/tests/Fixtures/Integration/Models/Company.php @@ -0,0 +1,16 @@ +name; + } + + #[Field] + public function getContact( + Company $company, + #[Prefetch('prefetchContacts')] + array $contacts + ): ?Contact { + return $contacts[$company->name] ?? null; + } + + public static function prefetchContacts(array $companies): array + { + $contacts = []; + + foreach ($companies as $company) { + $contacts[$company->name] = new Contact('Kate'); + } + + return $contacts; + } +} diff --git a/tests/Fixtures/Integration/Types/ContactType.php b/tests/Fixtures/Integration/Types/ContactType.php index a347815879..33ff5449f9 100644 --- a/tests/Fixtures/Integration/Types/ContactType.php +++ b/tests/Fixtures/Integration/Types/ContactType.php @@ -3,6 +3,7 @@ namespace TheCodingMachine\GraphQLite\Fixtures\Integration\Types; +use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Post; use TheCodingMachine\GraphQLite\Annotations\Prefetch; use function array_search; use function strtoupper; @@ -53,4 +54,56 @@ public static function prefetchContacts(iterable $contacts, string $prefix) 'prefix' => $prefix ]; } + + /** + * + * @return Post[]|null + */ + #[Field] + public function getPosts( + Contact $contact, + #[Prefetch('prefetchPosts')] + $posts + ): ?array { + return $posts[$contact->getName()] ?? null; + } + + public static function prefetchPosts(iterable $contacts): array + { + $posts = []; + foreach ($contacts as $contact) { + $contactPost = array_filter( + self::getContactPosts(), + fn(Post $post) => $post->author?->getName() === $contact->getName() + ); + + if (!$contactPost) { + continue; + } + + $posts[$contact->getName()] = $contactPost; + } + + return $posts; + } + + private static function getContactPosts(): array + { + return [ + self::generatePost('First Joe post', '1', new Contact('Joe')), + self::generatePost('First Bill post', '2', new Contact('Bill')), + self::generatePost('First Kate post', '3', new Contact('Kate')), + ]; + } + + private static function generatePost( + string $title, + string $id, + Contact $author, + ): Post { + $post = new Post($title); + $post->id = $id; + $post->author = $author; + return $post; + } } diff --git a/tests/Fixtures/Integration/Types/PostType.php b/tests/Fixtures/Integration/Types/PostType.php new file mode 100644 index 0000000000..565c783ecb --- /dev/null +++ b/tests/Fixtures/Integration/Types/PostType.php @@ -0,0 +1,25 @@ +id; + } + + #[Field] + public function getTitle(Post $post): string + { + return $post->title; + } +} diff --git a/tests/Integration/EndToEndTest.php b/tests/Integration/EndToEndTest.php index f58e80ffb2..d8522cc275 100644 --- a/tests/Integration/EndToEndTest.php +++ b/tests/Integration/EndToEndTest.php @@ -8,6 +8,9 @@ use GraphQL\Error\DebugFlag; use GraphQL\Executor\ExecutionResult; use GraphQL\GraphQL; +use GraphQL\Server\Helper; +use GraphQL\Server\OperationParams; +use GraphQL\Server\ServerConfig; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; use stdClass; @@ -21,6 +24,7 @@ use TheCodingMachine\GraphQLite\Containers\EmptyContainer; use TheCodingMachine\GraphQLite\Containers\LazyContainer; use TheCodingMachine\GraphQLite\Context\Context; +use TheCodingMachine\GraphQLite\Exceptions\WebonyxErrorHandler; use TheCodingMachine\GraphQLite\FieldsBuilder; use TheCodingMachine\GraphQLite\Fixtures\Inputs\ValidationException; use TheCodingMachine\GraphQLite\Fixtures\Inputs\Validator; @@ -178,6 +182,70 @@ public function testEndToEnd(): void ], $this->getSuccessResult($result)); } + public function testBatchPrefetching(): void + { + $schema = $this->mainContainer->get(Schema::class); + assert($schema instanceof Schema); + + $schema->assertValid(); + + $queryContact = 'query { contact (name: "Joe") { name posts { id title } } } '; + $queryCompanyWithContact = 'query { company (id: "1"){ name contact { name posts { id title } } } } '; + + $config = ServerConfig::create( + [ + 'schema' => $schema, + 'context' => new Context(), + 'queryBatching' => true, + 'errorFormatter' => [WebonyxErrorHandler::class, 'errorFormatter'], + 'errorsHandler' => [WebonyxErrorHandler::class, 'errorHandler'], + ] + ); + + $result = (new Helper())->executeBatch( + $config, + [ + /** Set specific prefetch result to buffer */ + OperationParams::create(['query' => $queryContact]), + /** Use prefetch data from previous operation instead of getting specific prefetch */ + OperationParams::create(['query' => $queryCompanyWithContact]), + ] + ); + + $this->assertSame( + [ + 'contact' => [ + 'name' => 'Joe', + 'posts' => [ + [ + 'id' => 1, + 'title' => 'First Joe post', + ], + ], + ], + ], + $this->getSuccessResult($result[0]) + ); + + $this->assertSame( + [ + 'company' => [ + 'name' => 'Company', + 'contact' => [ + 'name' => 'Kate', + 'posts' => [ + [ + 'id' => 3, + 'title' => 'First Kate post', + ], + ], + ], + ], + ], + $this->getSuccessResult($result[1]) + ); + } + public function testDeprecatedField(): void { $schema = $this->mainContainer->get(Schema::class); diff --git a/tests/SchemaFactoryTest.php b/tests/SchemaFactoryTest.php index 908f265f57..9ac3edceb9 100644 --- a/tests/SchemaFactoryTest.php +++ b/tests/SchemaFactoryTest.php @@ -18,12 +18,16 @@ use TheCodingMachine\GraphQLite\Containers\BasicAutoWiringContainer; use TheCodingMachine\GraphQLite\Containers\EmptyContainer; use TheCodingMachine\GraphQLite\Fixtures\Integration\Controllers\ContactController; +use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Company; use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Contact; +use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Post; use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\User; +use TheCodingMachine\GraphQLite\Fixtures\Integration\Types\CompanyType; use TheCodingMachine\GraphQLite\Fixtures\Integration\Types\ContactFactory; use TheCodingMachine\GraphQLite\Fixtures\Integration\Types\ContactOtherType; use TheCodingMachine\GraphQLite\Fixtures\Integration\Types\ContactType; use TheCodingMachine\GraphQLite\Fixtures\Integration\Types\ExtendedContactType; +use TheCodingMachine\GraphQLite\Fixtures\Integration\Types\PostType; use TheCodingMachine\GraphQLite\Fixtures\TestSelfType; use TheCodingMachine\GraphQLite\Mappers\CompositeTypeMapper; use TheCodingMachine\GraphQLite\Mappers\DuplicateMappingException; @@ -118,6 +122,10 @@ public function testCreateSchemaOnlyWithFactories(): void ContactFactory::class, ContactOtherType::class, ContactType::class, + Post::class, + PostType::class, + Company::class, + CompanyType::class, ExtendedContactType::class, User::class, ])); From c6f8e08284babbb33016acbff2b18a17793743e8 Mon Sep 17 00:00:00 2001 From: Shish Date: Fri, 15 Mar 2024 06:38:48 +0000 Subject: [PATCH 057/108] Use Laminas as example server (#661) (The Zend libraries were deprecated in 2019, and appear to be php7-only, which itself was killed in 2022) --- website/docs/other-frameworks.mdx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/website/docs/other-frameworks.mdx b/website/docs/other-frameworks.mdx index 71cc9de3aa..64806bd84a 100644 --- a/website/docs/other-frameworks.mdx +++ b/website/docs/other-frameworks.mdx @@ -174,7 +174,7 @@ $builder->useAutomaticPersistedQueries($cache, new DateInterval('PT1H')); In this example, we will focus on getting a working version of GraphQLite using: -- [Zend Stratigility](https://docs.zendframework.com/zend-stratigility/) as a PSR-15 server +- [Laminas Stratigility](https://docs.laminas.dev/laminas-stratigility/) as a PSR-15 server - `mouf/picotainer` (a micro-container) for the PSR-11 container - `symfony/cache ` for the PSR-16 cache @@ -189,9 +189,9 @@ The choice of the libraries is really up to you. You can adapt it based on your }, "require": { "thecodingmachine/graphqlite": "^4", - "zendframework/zend-diactoros": "^2", - "zendframework/zend-stratigility": "^3", - "zendframework/zend-httphandlerrunner": "^1.0", + "laminas/laminas-diactoros": "^2", + "laminas/laminas-stratigility": "^3", + "laminas/laminas-httphandlerrunner": "^2", "mouf/picotainer": "^1.1", "symfony/cache": "^4.2" }, @@ -206,11 +206,11 @@ The choice of the libraries is really up to you. You can adapt it based on your use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\ServerRequestFactory; -use Zend\HttpHandlerRunner\Emitter\SapiStreamEmitter; -use Zend\Stratigility\Middleware\ErrorResponseGenerator; -use Zend\Stratigility\MiddlewarePipe; +use Laminas\HttpHandlerRunner\Emitter\SapiStreamEmitter; +use Laminas\Stratigility\Middleware\ErrorResponseGenerator; +use Laminas\Stratigility\MiddlewarePipe; use Laminas\Diactoros\Server; -use Zend\HttpHandlerRunner\RequestHandlerRunner; +use Laminas\HttpHandlerRunner\RequestHandlerRunner; require_once __DIR__ . '/vendor/autoload.php'; @@ -232,7 +232,7 @@ $runner = new RequestHandlerRunner( $runner->run(); ``` -Here we are initializing a Zend `RequestHandler` (it receives requests) and we pass it to a Zend Stratigility `MiddlewarePipe`. +Here we are initializing a Laminas `RequestHandler` (it receives requests) and we pass it to a Laminas Stratigility `MiddlewarePipe`. This `MiddlewarePipe` comes from the container declared in the `config/container.php` file: ```php title="config/container.php" @@ -245,7 +245,7 @@ use Psr\SimpleCache\CacheInterface; use Symfony\Component\Cache\Simple\ApcuCache; use TheCodingMachine\GraphQLite\Http\Psr15GraphQLMiddlewareBuilder; use TheCodingMachine\GraphQLite\SchemaFactory; -use Zend\Stratigility\MiddlewarePipe; +use Laminas\Stratigility\MiddlewarePipe; // Picotainer is a minimalist PSR-11 container. return new Picotainer([ From 9fc7f9ee2ed07cc79fcd1d1275faa36f3174a53f Mon Sep 17 00:00:00 2001 From: Yurii <141632421+fogrye@users.noreply.github.com> Date: Wed, 20 Mar 2024 04:14:46 +0100 Subject: [PATCH 058/108] feat: replace thecodingmachine/class-explorer with kcs/class-finder (#664) * feat: replace thecodingmachine/class-explorer with kcs/class-finder Main issue is to let type mappers find types in vendor packages which class-explorer and maintainer is not updating the project. Symfony and Laravel bundles have to be updated too. Fixes: #657 * test: check that incorrect classes don't trigger autoloading errors Thanks @oprypkhantc * fix: stop caching reflections * fix: static checks * fix: stop using cached classes if restoring fails * test: improve coverage * fix: prevent possible issue with long-running apps As each condition applied to finder stays there each place it is applied should be done on cloned instance to avoid accumulation. Main instance of finder is kept with all the conditions provided during configuring. NS is created only from NSFctory where it's cloned so no need to add same clone inside NS. --- composer.json | 4 +- src/GlobControllerQueryProvider.php | 31 ++-- src/SchemaFactory.php | 12 +- src/Utils/Namespaces/NS.php | 70 ++++++--- src/Utils/Namespaces/NamespaceFactory.php | 13 +- .../BadNamespace/BadlyNamespacedClass.php | 8 + .../BadNamespace/ClassWithoutNamespace.php | 5 + tests/Fixtures/Types/EnumType.php | 8 + tests/GlobControllerQueryProviderTest.php | 23 +-- tests/SchemaFactoryTest.php | 11 +- tests/Utils/NsTest.php | 144 ++++++++++++++++++ 11 files changed, 258 insertions(+), 71 deletions(-) create mode 100644 tests/Fixtures/BadNamespace/BadlyNamespacedClass.php create mode 100644 tests/Fixtures/BadNamespace/ClassWithoutNamespace.php create mode 100644 tests/Fixtures/Types/EnumType.php create mode 100644 tests/Utils/NsTest.php diff --git a/composer.json b/composer.json index 24dab77d1d..d3e79e0426 100644 --- a/composer.json +++ b/composer.json @@ -25,8 +25,8 @@ "symfony/cache": "^4.3 || ^5 || ^6 || ^7", "symfony/expression-language": "^4 || ^5 || ^6 || ^7", "thecodingmachine/cache-utils": "^1", - "thecodingmachine/class-explorer": "^1.1.0", - "webonyx/graphql-php": "^v15.0" + "webonyx/graphql-php": "^v15.0", + "kcs/class-finder": "^0.4.0" }, "require-dev": { "beberlei/porpaginas": "^1.2 || ^2.0", diff --git a/src/GlobControllerQueryProvider.php b/src/GlobControllerQueryProvider.php index 85998c780b..6fc7e659d1 100644 --- a/src/GlobControllerQueryProvider.php +++ b/src/GlobControllerQueryProvider.php @@ -6,14 +6,14 @@ use GraphQL\Type\Definition\FieldDefinition; use InvalidArgumentException; -use Mouf\Composer\ClassNameMapper; +use Kcs\ClassFinder\Finder\ComposerFinder; +use Kcs\ClassFinder\Finder\FinderInterface; use Psr\Container\ContainerInterface; use Psr\SimpleCache\CacheInterface; use ReflectionClass; use ReflectionMethod; use Symfony\Component\Cache\Adapter\Psr16Adapter; use Symfony\Contracts\Cache\CacheInterface as CacheContractInterface; -use TheCodingMachine\ClassExplorer\Glob\GlobClassExplorer; use TheCodingMachine\GraphQLite\Annotations\Mutation; use TheCodingMachine\GraphQLite\Annotations\Query; use TheCodingMachine\GraphQLite\Annotations\Subscription; @@ -33,27 +33,25 @@ final class GlobControllerQueryProvider implements QueryProviderInterface { /** @var array|null */ private array|null $instancesList = null; - private ClassNameMapper $classNameMapper; + private FinderInterface $finder; private AggregateControllerQueryProvider|null $aggregateControllerQueryProvider = null; private CacheContractInterface $cacheContract; /** * @param string $namespace The namespace that contains the GraphQL types (they must have a `@Type` annotation) * @param ContainerInterface $container The container we will fetch controllers from. - * @param bool $recursive Whether subnamespaces of $namespace must be analyzed. */ public function __construct( - private string $namespace, - private FieldsBuilder $fieldsBuilder, - private ContainerInterface $container, - private AnnotationReader $annotationReader, - private CacheInterface $cache, - ClassNameMapper|null $classNameMapper = null, - private int|null $cacheTtl = null, - private bool $recursive = true, + private readonly string $namespace, + private readonly FieldsBuilder $fieldsBuilder, + private readonly ContainerInterface $container, + private readonly AnnotationReader $annotationReader, + private readonly CacheInterface $cache, + FinderInterface|null $finder = null, + int|null $cacheTtl = null, ) { - $this->classNameMapper = $classNameMapper ?? ClassNameMapper::createFromComposerFile(null, null, true); + $this->finder = $finder ?? new ComposerFinder(); $this->cacheContract = new Psr16Adapter( $this->cache, str_replace(['\\', '{', '}', '(', ')', '/', '@', ':'], '_', $namespace), @@ -96,15 +94,12 @@ private function getInstancesList(): array /** @return array */ private function buildInstancesList(): array { - $explorer = new GlobClassExplorer($this->namespace, $this->cache, $this->cacheTtl, $this->classNameMapper, $this->recursive); - $classes = $explorer->getClasses(); $instances = []; - foreach ($classes as $className) { + foreach ((clone $this->finder)->inNamespace($this->namespace) as $className => $refClass) { if (! class_exists($className) && ! interface_exists($className)) { continue; } - $refClass = new ReflectionClass($className); - if (! $refClass->isInstantiable()) { + if (! $refClass instanceof ReflectionClass || ! $refClass->isInstantiable()) { continue; } if (! $this->hasOperations($refClass)) { diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index 8f23bf5df6..e49f51e8a1 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -8,7 +8,7 @@ use Doctrine\Common\Annotations\PsrCachedReader; use Doctrine\Common\Annotations\Reader; use GraphQL\Type\SchemaConfig; -use Mouf\Composer\ClassNameMapper; +use Kcs\ClassFinder\Finder\FinderInterface; use MyCLabs\Enum\Enum; use PackageVersions\Versions; use Psr\Cache\CacheItemPoolInterface; @@ -109,7 +109,7 @@ class SchemaFactory private NamingStrategyInterface|null $namingStrategy = null; - private ClassNameMapper|null $classNameMapper = null; + private FinderInterface|null $finder = null; private SchemaConfig|null $schemaConfig = null; @@ -262,9 +262,9 @@ public function setSchemaConfig(SchemaConfig $schemaConfig): self return $this; } - public function setClassNameMapper(ClassNameMapper $classNameMapper): self + public function setFinder(FinderInterface $finder): self { - $this->classNameMapper = $classNameMapper; + $this->finder = $finder; return $this; } @@ -344,7 +344,7 @@ public function createSchema(): Schema $namingStrategy = $this->namingStrategy ?: new NamingStrategy(); $typeRegistry = new TypeRegistry(); - $namespaceFactory = new NamespaceFactory($namespacedCache, $this->classNameMapper, $this->globTTL); + $namespaceFactory = new NamespaceFactory($namespacedCache, $this->finder, $this->globTTL); $nsList = array_map( static fn (string $namespace) => $namespaceFactory->createNamespace($namespace), $this->typeNamespaces, @@ -493,7 +493,7 @@ public function createSchema(): Schema $this->container, $annotationReader, $namespacedCache, - $this->classNameMapper, + $this->finder, $this->globTTL, ); } diff --git a/src/Utils/Namespaces/NS.php b/src/Utils/Namespaces/NS.php index 4e6fe98d99..262a42494b 100644 --- a/src/Utils/Namespaces/NS.php +++ b/src/Utils/Namespaces/NS.php @@ -4,13 +4,19 @@ namespace TheCodingMachine\GraphQLite\Utils\Namespaces; -use Mouf\Composer\ClassNameMapper; +use Exception; +use Kcs\ClassFinder\Finder\FinderInterface; +use Psr\SimpleCache\CacheException; use Psr\SimpleCache\CacheInterface; +use Psr\SimpleCache\InvalidArgumentException; use ReflectionClass; -use TheCodingMachine\ClassExplorer\Glob\GlobClassExplorer; +use ReflectionException; +use function array_keys; use function class_exists; use function interface_exists; +use function preg_replace; +use function trait_exists; /** * The NS class represents a PHP Namespace and provides utility methods to explore those classes. @@ -24,7 +30,7 @@ final class NS * Only instantiable classes are returned. * Key: fully qualified class name * - * @var array> + * @var array> */ private array|null $classes = null; @@ -32,10 +38,10 @@ final class NS public function __construct( private readonly string $namespace, private readonly CacheInterface $cache, - private readonly ClassNameMapper $classNameMapper, + private readonly FinderInterface $finder, private readonly int|null $globTTL, - private readonly bool $recursive, - ) { + ) + { } /** @@ -47,31 +53,47 @@ public function __construct( public function getClassList(): array { if ($this->classes === null) { - $this->classes = []; - $explorer = new GlobClassExplorer($this->namespace, $this->cache, $this->globTTL, $this->classNameMapper, $this->recursive); - /** @var array $classes Override class-explorer lib */ - $classes = $explorer->getClassMap(); - foreach ($classes as $className => $phpFile) { - if (! class_exists($className, false) && ! interface_exists($className, false)) { - // Let's try to load the file if it was not imported yet. - // We are importing the file manually to avoid triggering the autoloader. - // The autoloader might trigger errors if the file does not respect PSR-4 or if the - // Symfony DebugAutoLoader is installed. (see https://github.com/thecodingmachine/graphqlite/issues/216) - require_once $phpFile; - // Does it exists now? - // @phpstan-ignore-next-line - if (! class_exists($className, false) && ! interface_exists($className, false)) { - continue; + $cacheKey = 'GraphQLite_NS_' . preg_replace('/[\/{}()\\\\@:]/', '', $this->namespace); + try { + $classes = $this->cache->get($cacheKey); + if ($classes !== null) { + foreach ($classes as $class) { + if ( + ! class_exists($class, false) && + ! interface_exists($class, false) && + ! trait_exists($class, false) + ) { + // assume the cache is invalid + throw new class extends Exception implements CacheException { + }; + } + + $this->classes[$class] = new ReflectionClass($class); } } + } catch (CacheException | InvalidArgumentException | ReflectionException) { + $this->classes = null; + } - $refClass = new ReflectionClass($className); + if ($this->classes === null) { + $this->classes = []; + /** @var class-string $className */ + /** @var ReflectionClass $reflector */ + foreach ($this->finder->inNamespace($this->namespace) as $className => $reflector) { + if (! ($reflector instanceof ReflectionClass)) { + continue; + } - $this->classes[$className] = $refClass; + $this->classes[$className] = $reflector; + } + try { + $this->cache->set($cacheKey, array_keys($this->classes), $this->globTTL); + } catch (InvalidArgumentException) { + // @ignoreException + } } } - // @phpstan-ignore-next-line - Not sure why we cannot annotate the $classes above return $this->classes; } diff --git a/src/Utils/Namespaces/NamespaceFactory.php b/src/Utils/Namespaces/NamespaceFactory.php index 9d1c6d32cf..e4d462cc2a 100644 --- a/src/Utils/Namespaces/NamespaceFactory.php +++ b/src/Utils/Namespaces/NamespaceFactory.php @@ -4,7 +4,8 @@ namespace TheCodingMachine\GraphQLite\Utils\Namespaces; -use Mouf\Composer\ClassNameMapper; +use Kcs\ClassFinder\Finder\ComposerFinder; +use Kcs\ClassFinder\Finder\FinderInterface; use Psr\SimpleCache\CacheInterface; /** @@ -14,16 +15,16 @@ */ final class NamespaceFactory { - private ClassNameMapper $classNameMapper; + private FinderInterface $finder; - public function __construct(private readonly CacheInterface $cache, ClassNameMapper|null $classNameMapper = null, private int|null $globTTL = 2) + public function __construct(private readonly CacheInterface $cache, FinderInterface|null $finder = null, private int|null $globTTL = 2) { - $this->classNameMapper = $classNameMapper ?? ClassNameMapper::createFromComposerFile(null, null, true); + $this->finder = $finder ?? new ComposerFinder(); } /** @param string $namespace A PHP namespace */ - public function createNamespace(string $namespace, bool $recursive = true): NS + public function createNamespace(string $namespace): NS { - return new NS($namespace, $this->cache, $this->classNameMapper, $this->globTTL, $recursive); + return new NS($namespace, $this->cache, clone $this->finder, $this->globTTL); } } diff --git a/tests/Fixtures/BadNamespace/BadlyNamespacedClass.php b/tests/Fixtures/BadNamespace/BadlyNamespacedClass.php new file mode 100644 index 0000000000..258dac6fd5 --- /dev/null +++ b/tests/Fixtures/BadNamespace/BadlyNamespacedClass.php @@ -0,0 +1,8 @@ + $controller ]) implements ContainerInterface { - /** - * @var array - */ + $container = new class ([TestController::class => $controller]) implements ContainerInterface { + /** @var array */ private $controllers; public function __construct(array $controllers) @@ -24,26 +26,27 @@ public function __construct(array $controllers) $this->controllers = $controllers; } - public function get($id):mixed + public function get($id): mixed { return $this->controllers[$id]; } - public function has($id):bool + public function has($id): bool { return isset($this->controllers[$id]); } }; + $finder = new ComposerFinder(); + $finder->filter(static fn (ReflectionClass $class) => $class->getNamespaceName() === 'TheCodingMachine\\GraphQLite\\Fixtures'); // Fix for recursive:false $globControllerQueryProvider = new GlobControllerQueryProvider( 'TheCodingMachine\\GraphQLite\\Fixtures', $this->getFieldsBuilder(), $container, $this->getAnnotationReader(), - new Psr16Cache(new NullAdapter), - null, - false, - false, + new Psr16Cache(new NullAdapter()), + $finder, + 0, ); $queries = $globControllerQueryProvider->getQueries(); diff --git a/tests/SchemaFactoryTest.php b/tests/SchemaFactoryTest.php index 9ac3edceb9..d57dc78a11 100644 --- a/tests/SchemaFactoryTest.php +++ b/tests/SchemaFactoryTest.php @@ -9,7 +9,8 @@ use GraphQL\Executor\ExecutionResult; use GraphQL\GraphQL; use GraphQL\Type\SchemaConfig; -use Mouf\Composer\ClassNameMapper; +use Kcs\ClassFinder\Finder\ComposerFinder; +use Kcs\ClassFinder\Finder\RecursiveFinder; use PHPUnit\Framework\TestCase; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Adapter\Psr16Adapter; @@ -89,7 +90,7 @@ public function testSetters(): void $this->doTestSchema($schema); } - public function testClassNameMapperInjectionWithValidMapper(): void + public function testFinderInjectionWithValidMapper(): void { $factory = new SchemaFactory( new Psr16Cache(new ArrayAdapter()), @@ -99,7 +100,7 @@ public function testClassNameMapperInjectionWithValidMapper(): void ); $factory->setAuthenticationService(new VoidAuthenticationService()) ->setAuthorizationService(new VoidAuthorizationService()) - ->setClassNameMapper(ClassNameMapper::createFromComposerFile(null, null, true)) + ->setFinder(new ComposerFinder()) ->addControllerNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers') ->addTypeNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); @@ -136,7 +137,7 @@ public function testCreateSchemaOnlyWithFactories(): void $this->doTestSchema($schema); } - public function testClassNameMapperInjectionWithInvalidMapper(): void + public function testFinderInjectionWithInvalidMapper(): void { $factory = new SchemaFactory( new Psr16Cache(new ArrayAdapter()), @@ -146,7 +147,7 @@ public function testClassNameMapperInjectionWithInvalidMapper(): void ); $factory->setAuthenticationService(new VoidAuthenticationService()) ->setAuthorizationService(new VoidAuthorizationService()) - ->setClassNameMapper(new ClassNameMapper()) + ->setFinder(new RecursiveFinder(__DIR__ . '/Annotations')) ->addControllerNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers') ->addTypeNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); diff --git a/tests/Utils/NsTest.php b/tests/Utils/NsTest.php new file mode 100644 index 0000000000..84f92c1ccd --- /dev/null +++ b/tests/Utils/NsTest.php @@ -0,0 +1,144 @@ +cache = new Psr16Cache(new ArrayAdapter()); + $this->namespace = 'TheCodingMachine\GraphQLite\Fixtures\Types'; + $this->finder = new ComposerFinder(); + $this->globTTL = 10; + } + + /** @dataProvider loadsClassListProvider */ + public function testLoadsClassList(array $expectedClasses, string $namespace): void + { + $ns = new NS( + namespace: $namespace, + cache: $this->cache, + finder: $this->finder, + globTTL: null, + ); + + self::assertEqualsCanonicalizing($expectedClasses, array_keys($ns->getClassList())); + } + + public static function loadsClassListProvider(): iterable + { + yield 'autoload' => [ + [ + TestFactory::class, + GetterSetterType::class, + FooType::class, + MagicGetterSetterType::class, + FooExtendType::class, + NoTypeAnnotation::class, + AbstractFooType::class, + EnumType::class + ], + 'TheCodingMachine\GraphQLite\Fixtures\Types', + ]; + + // The class should be ignored. + yield 'incorrect namespace class without autoload' => [ + [], + 'TheCodingMachine\GraphQLite\Fixtures\BadNamespace', + ]; + } + + public function testCaching(): void + { + $ns = new NS( + namespace: $this->namespace, + cache: $this->cache, + finder: $this->finder, + globTTL: $this->globTTL, + ); + self::assertNotNull($ns->getClassList()); + + // create with mock finder to test cache + $finder = $this->createMock(FinderInterface::class); + $finder->expects(self::never())->method('inNamespace')->willReturnSelf(); + $ns = new NS( + namespace: $this->namespace, + cache: $this->cache, + finder: $finder, + globTTL: $this->globTTL, + ); + self::assertNotNull($ns->getClassList()); + } + + public function testCachingWithInvalidKey(): void + { + $exception = new class extends Exception implements InvalidArgumentException { + }; + $cache = $this->createMock(CacheInterface::class); + $cache->expects(self::once())->method('get')->willThrowException($exception); + $cache->expects(self::once())->method('set')->willThrowException($exception); + $ns = new NS( + namespace: $this->namespace, + cache: $cache, + finder: $this->finder, + globTTL: $this->globTTL, + ); + $ns->getClassList(); + } + + public function testCachingWithInvalidCache(): void + { + $cache = $this->createMock(CacheInterface::class); + $cache->expects(self::once())->method('get')->willReturn(['foo']); + $ns = new NS( + namespace: $this->namespace, + cache: $cache, + finder: $this->finder, + globTTL: $this->globTTL, + ); + $classList = $ns->getClassList(); + self::assertNotNull($classList); + self::assertNotEmpty($classList); + } + + public function testFinderWithUnexpectedOutput() { + + $finder = $this->createMock(FinderInterface::class); + $finder->expects(self::once())->method('inNamespace')->willReturnSelf(); + $finder->expects(self::once())->method('getIterator')->willReturn(new \ArrayIterator([ 'test' => new \ReflectionException()])); + $ns = new NS( + namespace: $this->namespace, + cache: $this->cache, + finder: $finder, + globTTL: $this->globTTL, + ); + $classList = $ns->getClassList(); + self::assertNotNull($classList); + self::assertEmpty($classList);} +} From 19458fabf2113ac75f67f157a8d6c73d47a02ecc Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Wed, 20 Mar 2024 01:50:51 -0400 Subject: [PATCH 059/108] Added 7.0.0 to docs (#665) --- website/README | 22 +- website/docs/CHANGELOG.md | 123 +++- .../versioned_docs/version-7.0.0/CHANGELOG.md | 216 ++++++ .../versioned_docs/version-7.0.0/README.mdx | 121 +++ .../version-7.0.0/annotations-reference.md | 320 ++++++++ .../version-7.0.0/argument-resolving.md | 164 +++++ .../authentication-authorization.mdx | 301 ++++++++ .../automatic-persisted-queries.mdx | 61 ++ .../version-7.0.0/autowiring.mdx | 171 +++++ .../version-7.0.0/custom-types.mdx | 272 +++++++ .../doctrine-annotations-attributes.mdx | 164 +++++ .../version-7.0.0/error-handling.mdx | 222 ++++++ .../version-7.0.0/extend-input-type.mdx | 136 ++++ .../version-7.0.0/extend-type.mdx | 269 +++++++ .../external-type-declaration.mdx | 295 ++++++++ .../version-7.0.0/field-middlewares.md | 139 ++++ .../version-7.0.0/file-uploads.mdx | 91 +++ .../version-7.0.0/fine-grained-security.mdx | 421 +++++++++++ .../version-7.0.0/getting-started.md | 16 + .../version-7.0.0/implementing-security.md | 57 ++ .../version-7.0.0/inheritance-interfaces.mdx | 312 ++++++++ .../version-7.0.0/input-types.mdx | 696 ++++++++++++++++++ .../versioned_docs/version-7.0.0/internals.md | 142 ++++ .../laravel-package-advanced.mdx | 343 +++++++++ .../version-7.0.0/laravel-package.md | 153 ++++ .../versioned_docs/version-7.0.0/migrating.md | 54 ++ .../version-7.0.0/multiple-output-types.mdx | 256 +++++++ .../version-7.0.0/mutations.mdx | 60 ++ .../version-7.0.0/operation-complexity.md | 223 ++++++ .../version-7.0.0/other-frameworks.mdx | 342 +++++++++ .../version-7.0.0/pagination.mdx | 99 +++ .../version-7.0.0/prefetch-method.mdx | 198 +++++ .../versioned_docs/version-7.0.0/queries.mdx | 251 +++++++ .../version-7.0.0/query-plan.mdx | 109 +++ .../versioned_docs/version-7.0.0/semver.md | 45 ++ .../version-7.0.0/subscriptions.mdx | 53 ++ .../version-7.0.0/symfony-bundle-advanced.mdx | 229 ++++++ .../version-7.0.0/symfony-bundle.md | 121 +++ .../version-7.0.0/troubleshooting.md | 25 + .../version-7.0.0/type-mapping.mdx | 664 +++++++++++++++++ .../universal-service-providers.md | 74 ++ .../version-7.0.0/validation.mdx | 287 ++++++++ .../version-7.0.0-sidebars.json | 58 ++ website/versions.json | 1 + 44 files changed, 8353 insertions(+), 23 deletions(-) create mode 100644 website/versioned_docs/version-7.0.0/CHANGELOG.md create mode 100644 website/versioned_docs/version-7.0.0/README.mdx create mode 100644 website/versioned_docs/version-7.0.0/annotations-reference.md create mode 100644 website/versioned_docs/version-7.0.0/argument-resolving.md create mode 100644 website/versioned_docs/version-7.0.0/authentication-authorization.mdx create mode 100644 website/versioned_docs/version-7.0.0/automatic-persisted-queries.mdx create mode 100644 website/versioned_docs/version-7.0.0/autowiring.mdx create mode 100644 website/versioned_docs/version-7.0.0/custom-types.mdx create mode 100644 website/versioned_docs/version-7.0.0/doctrine-annotations-attributes.mdx create mode 100644 website/versioned_docs/version-7.0.0/error-handling.mdx create mode 100644 website/versioned_docs/version-7.0.0/extend-input-type.mdx create mode 100644 website/versioned_docs/version-7.0.0/extend-type.mdx create mode 100644 website/versioned_docs/version-7.0.0/external-type-declaration.mdx create mode 100644 website/versioned_docs/version-7.0.0/field-middlewares.md create mode 100644 website/versioned_docs/version-7.0.0/file-uploads.mdx create mode 100644 website/versioned_docs/version-7.0.0/fine-grained-security.mdx create mode 100644 website/versioned_docs/version-7.0.0/getting-started.md create mode 100644 website/versioned_docs/version-7.0.0/implementing-security.md create mode 100644 website/versioned_docs/version-7.0.0/inheritance-interfaces.mdx create mode 100644 website/versioned_docs/version-7.0.0/input-types.mdx create mode 100644 website/versioned_docs/version-7.0.0/internals.md create mode 100644 website/versioned_docs/version-7.0.0/laravel-package-advanced.mdx create mode 100644 website/versioned_docs/version-7.0.0/laravel-package.md create mode 100644 website/versioned_docs/version-7.0.0/migrating.md create mode 100644 website/versioned_docs/version-7.0.0/multiple-output-types.mdx create mode 100644 website/versioned_docs/version-7.0.0/mutations.mdx create mode 100644 website/versioned_docs/version-7.0.0/operation-complexity.md create mode 100644 website/versioned_docs/version-7.0.0/other-frameworks.mdx create mode 100644 website/versioned_docs/version-7.0.0/pagination.mdx create mode 100644 website/versioned_docs/version-7.0.0/prefetch-method.mdx create mode 100644 website/versioned_docs/version-7.0.0/queries.mdx create mode 100644 website/versioned_docs/version-7.0.0/query-plan.mdx create mode 100644 website/versioned_docs/version-7.0.0/semver.md create mode 100644 website/versioned_docs/version-7.0.0/subscriptions.mdx create mode 100644 website/versioned_docs/version-7.0.0/symfony-bundle-advanced.mdx create mode 100644 website/versioned_docs/version-7.0.0/symfony-bundle.md create mode 100644 website/versioned_docs/version-7.0.0/troubleshooting.md create mode 100644 website/versioned_docs/version-7.0.0/type-mapping.mdx create mode 100644 website/versioned_docs/version-7.0.0/universal-service-providers.md create mode 100644 website/versioned_docs/version-7.0.0/validation.mdx create mode 100644 website/versioned_sidebars/version-7.0.0-sidebars.json diff --git a/website/README b/website/README index 90008215f6..6702a98e28 100644 --- a/website/README +++ b/website/README @@ -26,17 +26,23 @@ The [versioning section of the Docusaurus documentation](https://docusaurus.io/d 1. Firstly, you need to update the `docs/CHANGELOG.md` file with the updates for the release. The best way to do this is to review all the `is:merged` PRs since the last release. Here is an example entry format: ```markdown -#### Dependencies: -https://github.com/thecodingmachine/graphqlite/pull/000 Upgraded to Symfony 69 +## 1.0.0 -#### Breaking Change: -https://github.com/thecodingmachine/graphqlite/pull/000 Dropped PHP support +### Dependencies: -#### New Features: -https://github.com/thecodingmachine/graphqlite/pull/000 Added support for the new GraphQL spec fork +- #000 Upgraded to Symfony 69 -#### Miscellaneous: -Added John Doe as a maintainer +### Breaking Change: + +- #000 Dropped PHP support + +### New Features: + +- #000 Added support for the new GraphQL spec fork + +### Miscellaneous: + +- Added John Doe as a maintainer ``` 2. Next, you'll need to tag a new version with Docusaurus, which will cache a copy of the documentation in the `versioned_docs` directory, update the `versioned_sidebars`, and update the `versions.json` file. diff --git a/website/docs/CHANGELOG.md b/website/docs/CHANGELOG.md index 2e98e0ac9d..feb5972c91 100644 --- a/website/docs/CHANGELOG.md +++ b/website/docs/CHANGELOG.md @@ -4,28 +4,123 @@ title: Changelog sidebar_label: Changelog --- +## 7.0.0 + +### Breaking Changes + +- #664 Replaces [thecodingmachine/class-explorer](https://github.com/thecodingmachine/class-explorer) with [kcs/class-finder](https://github.com/alekitto/class-finder) resulting in the `SchemaFactory::setClassNameMapper` being renamed to `SchemaFactory::setFinder`. This now expects an instance of `Kcs\ClassFinder\Finder` instead of `Kcs\ClassFinder\Finder\FinderInterface`. @fogrye + +### New Features + +- #649 Adds support for `subscription` operations. @oojacoboo +- #612 Automatic query complexity analysis. @oprypkhantc +- #611 Automatic persisted queries. @oprypkhantc + +### Improvements + +- #658 Improves on prefetching for nested fields. @grynchuk +- #646 Improves exception handling during schema parsing. @fogrye +- #636 Allows the use of middleware on construtor params/fields. @oprypkhantc +- #623 Improves support for description arguments on types/fields. @downace +- #628 Properly handles `@param` annotations for generics support on field annotated constructor arguments. @oojacoboo +- #584 Immutability improvements across the codebase. @oprypkhantc +- #588 Prefetch improvements. @oprpkhantc +- #606 Adds support for phpdoc descriptions and deprecation annotations on native enums. @mdoelker +- Thanks to @shish, @cvergne and @mshapovalov for updating the docs! + +### Minor Changes + +- #639 Added support for Symfony 7. @janatjak + + +## 6.2.3 + +Adds support for `Psr\Container` 1.1 with #601 + +## 6.2.2 + +This is a very simple release. We support Doctrine annotation 1.x and we've deprecated `SchemaFactory::setDoctrineAnnotationReader` in favor of native PHP attributes. + +## 6.2.1 + +- Added support for new `Void` return types, allowing use of `void` from operation resolvers. #574 +- Improvements with authorization middleware #571 +- Updated vendor dependencies: #580 #558 + +## 6.2.0 + +Lots of little nuggets in this release! We're now targeting PHP ^8.1 and have testing on 8.2. + +- Better support for union types and enums: #530, #535, #561, #570 +- Various bug and interface fixes: #532, #575, #564 +- GraphQL v15 required: #542 +- Lots of codebase improvements, more strict typing: #548 + +A special thanks to @rusted-love and @oprypkhantc for their contributions. + +## 6.1.0 + +A shoutout to @bladl for his work on this release, improving the code for better typing and PHP 8.0 syntax updates! + +### Breaking Changes + +- #518 PSR-11 support now requires version 2 +- #508 Due to some of the code improvements, additional typing has been added to some interfaces/classes. For instance, `RootTypeMapperInterface::toGraphQLOutputType` and `RootTypeMapperInterface::toGraphQLInputType` now have the following signatures: + +```php + /** + * @param (OutputType&GraphQLType)|null $subType + * + * @return OutputType&GraphQLType + */ + public function toGraphQLOutputType( + Type $type, + OutputType|null $subType, + ReflectionMethod|ReflectionProperty $reflector, + DocBlock $docBlockObj + ): OutputType; + + /** + * @param (InputType&GraphQLType)|null $subType + * + * @return InputType&GraphQLType + */ + public function toGraphQLInputType( + Type $type, + InputType|null $subType, + string $argumentName, + ReflectionMethod|ReflectionProperty $reflector, + DocBlock $docBlockObj + ): InputType; +``` + +### Improvements + +- #510 +- #508 + ## 5.0.0 -#### Dependencies: +### Dependencies - Upgraded to using version 14.9 of [webonyx/graphql-php](https://github.com/webonyx/graphql-php) ## 4.3.0 -#### Breaking change: +### Breaking change - The method `setAnnotationCacheDir($directory)` has been removed from the `SchemaFactory`. The annotation cache will use your `Psr\SimpleCache\CacheInterface` compliant cache handler set through the `SchemaFactory` constructor. -#### Minor changes: +### Minor changes - Removed dependency for doctrine/cache and unified some of the cache layers following a PSR interface. - Cleaned up some of the documentation in an attempt to get things accurate with versioned releases. ## 4.2.0 -#### Breaking change: +### Breaking change The method signature for `toGraphQLOutputType` and `toGraphQLInputType` have been changed to the following: @@ -41,7 +136,7 @@ public function toGraphQLOutputType(Type $type, ?OutputType $subType, $reflector public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, $reflector, DocBlock $docBlockObj): InputType; ``` -#### New features: +### New features - [@Input](annotations-reference.md#input-annotation) annotation is introduced as an alternative to `@Factory`. Now GraphQL input type can be created in the same manner as `@Type` in combination with `@Field` - [example](input-types.mdx#input-attribute). - New attributes has been added to [@Field](annotations-reference.md#field-annotation) annotation: `for`, `inputType` and `description`. @@ -49,36 +144,35 @@ public function toGraphQLInputType(Type $type, ?InputType $subType, string $argu ## 4.1.0 -#### Breaking change: +### Breaking change There is one breaking change introduced in the minor version (this was important to allow PHP 8 compatibility). - The **ecodev/graphql-upload** package (used to get support for file uploads in GraphQL input types) is now a "recommended" dependency only. If you are using GraphQL file uploads, you need to add `ecodev/graphql-upload` to your `composer.json`. -#### New features: +### New features - All annotations can now be accessed as PHP 8 attributes - The `@deprecated` annotation in your PHP code translates into deprecated fields in your GraphQL schema - You can now specify the GraphQL name of the Enum types you define - Added the possibility to inject pure Webonyx objects in GraphQLite schema -#### Minor changes: +### Minor changes - Migrated from `zend/diactoros` to `laminas/diactoros` - Making the annotation cache directory configurable -#### Miscellaneous: +### Miscellaneous - Migrated from Travis to Github actions - ## 4.0.0 This is a complete refactoring from 3.x. While existing annotations are kept compatible, the internals have completely changed. -#### New features: +### New features - You can directly [annotate a PHP interface with `@Type` to make it a GraphQL interface](inheritance-interfaces.mdx#mapping-interfaces) - You can autowire services in resolvers, thanks to the new `@Autowire` annotation @@ -97,7 +191,6 @@ changed. - You can extend an input types (just like you could extend an output type in v3) using [the new `@Decorate` annotation](extend-input-type.mdx) - In a factory, you can [exclude some optional parameters from the GraphQL schema](input-types#ignoring-some-parameters) - Many extension points have been added - Added a "root type mapper" (useful to map scalar types to PHP types or to add custom annotations related to resolvers) @@ -106,15 +199,15 @@ Many extension points have been added New framework specific features: -#### Symfony: +### Symfony - The Symfony bundle now provides a "login" and a "logout" mutation (and also a "me" query) -#### Laravel: +### Laravel - [Native integration with the Laravel paginator](laravel-package-advanced.mdx#support-for-pagination) has been added -#### Internals: +### Internals - The `FieldsBuilder` class has been split in many different services (`FieldsBuilder`, `TypeHandler`, and a chain of *root type mappers*) diff --git a/website/versioned_docs/version-7.0.0/CHANGELOG.md b/website/versioned_docs/version-7.0.0/CHANGELOG.md new file mode 100644 index 0000000000..feb5972c91 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/CHANGELOG.md @@ -0,0 +1,216 @@ +--- +id: changelog +title: Changelog +sidebar_label: Changelog +--- + +## 7.0.0 + +### Breaking Changes + +- #664 Replaces [thecodingmachine/class-explorer](https://github.com/thecodingmachine/class-explorer) with [kcs/class-finder](https://github.com/alekitto/class-finder) resulting in the `SchemaFactory::setClassNameMapper` being renamed to `SchemaFactory::setFinder`. This now expects an instance of `Kcs\ClassFinder\Finder` instead of `Kcs\ClassFinder\Finder\FinderInterface`. @fogrye + +### New Features + +- #649 Adds support for `subscription` operations. @oojacoboo +- #612 Automatic query complexity analysis. @oprypkhantc +- #611 Automatic persisted queries. @oprypkhantc + +### Improvements + +- #658 Improves on prefetching for nested fields. @grynchuk +- #646 Improves exception handling during schema parsing. @fogrye +- #636 Allows the use of middleware on construtor params/fields. @oprypkhantc +- #623 Improves support for description arguments on types/fields. @downace +- #628 Properly handles `@param` annotations for generics support on field annotated constructor arguments. @oojacoboo +- #584 Immutability improvements across the codebase. @oprypkhantc +- #588 Prefetch improvements. @oprpkhantc +- #606 Adds support for phpdoc descriptions and deprecation annotations on native enums. @mdoelker +- Thanks to @shish, @cvergne and @mshapovalov for updating the docs! + +### Minor Changes + +- #639 Added support for Symfony 7. @janatjak + + +## 6.2.3 + +Adds support for `Psr\Container` 1.1 with #601 + +## 6.2.2 + +This is a very simple release. We support Doctrine annotation 1.x and we've deprecated `SchemaFactory::setDoctrineAnnotationReader` in favor of native PHP attributes. + +## 6.2.1 + +- Added support for new `Void` return types, allowing use of `void` from operation resolvers. #574 +- Improvements with authorization middleware #571 +- Updated vendor dependencies: #580 #558 + +## 6.2.0 + +Lots of little nuggets in this release! We're now targeting PHP ^8.1 and have testing on 8.2. + +- Better support for union types and enums: #530, #535, #561, #570 +- Various bug and interface fixes: #532, #575, #564 +- GraphQL v15 required: #542 +- Lots of codebase improvements, more strict typing: #548 + +A special thanks to @rusted-love and @oprypkhantc for their contributions. + +## 6.1.0 + +A shoutout to @bladl for his work on this release, improving the code for better typing and PHP 8.0 syntax updates! + +### Breaking Changes + +- #518 PSR-11 support now requires version 2 +- #508 Due to some of the code improvements, additional typing has been added to some interfaces/classes. For instance, `RootTypeMapperInterface::toGraphQLOutputType` and `RootTypeMapperInterface::toGraphQLInputType` now have the following signatures: + +```php + /** + * @param (OutputType&GraphQLType)|null $subType + * + * @return OutputType&GraphQLType + */ + public function toGraphQLOutputType( + Type $type, + OutputType|null $subType, + ReflectionMethod|ReflectionProperty $reflector, + DocBlock $docBlockObj + ): OutputType; + + /** + * @param (InputType&GraphQLType)|null $subType + * + * @return InputType&GraphQLType + */ + public function toGraphQLInputType( + Type $type, + InputType|null $subType, + string $argumentName, + ReflectionMethod|ReflectionProperty $reflector, + DocBlock $docBlockObj + ): InputType; +``` + +### Improvements + +- #510 +- #508 + +## 5.0.0 + +### Dependencies + +- Upgraded to using version 14.9 of [webonyx/graphql-php](https://github.com/webonyx/graphql-php) + +## 4.3.0 + +### Breaking change + +- The method `setAnnotationCacheDir($directory)` has been removed from the `SchemaFactory`. The annotation + cache will use your `Psr\SimpleCache\CacheInterface` compliant cache handler set through the `SchemaFactory` + constructor. + +### Minor changes + +- Removed dependency for doctrine/cache and unified some of the cache layers following a PSR interface. +- Cleaned up some of the documentation in an attempt to get things accurate with versioned releases. + +## 4.2.0 + +### Breaking change + +The method signature for `toGraphQLOutputType` and `toGraphQLInputType` have been changed to the following: + +```php +/** + * @param \ReflectionMethod|\ReflectionProperty $reflector + */ +public function toGraphQLOutputType(Type $type, ?OutputType $subType, $reflector, DocBlock $docBlockObj): OutputType; + +/** + * @param \ReflectionMethod|\ReflectionProperty $reflector + */ +public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, $reflector, DocBlock $docBlockObj): InputType; +``` + +### New features + +- [@Input](annotations-reference.md#input-annotation) annotation is introduced as an alternative to `@Factory`. Now GraphQL input type can be created in the same manner as `@Type` in combination with `@Field` - [example](input-types.mdx#input-attribute). +- New attributes has been added to [@Field](annotations-reference.md#field-annotation) annotation: `for`, `inputType` and `description`. +- The following annotations now can be applied to class properties directly: `@Field`, `@Logged`, `@Right`, `@FailWith`, `@HideIfUnauthorized` and `@Security`. + +## 4.1.0 + +### Breaking change + +There is one breaking change introduced in the minor version (this was important to allow PHP 8 compatibility). + +- The **ecodev/graphql-upload** package (used to get support for file uploads in GraphQL input types) is now a "recommended" dependency only. + If you are using GraphQL file uploads, you need to add `ecodev/graphql-upload` to your `composer.json`. + +### New features + +- All annotations can now be accessed as PHP 8 attributes +- The `@deprecated` annotation in your PHP code translates into deprecated fields in your GraphQL schema +- You can now specify the GraphQL name of the Enum types you define +- Added the possibility to inject pure Webonyx objects in GraphQLite schema + +### Minor changes + +- Migrated from `zend/diactoros` to `laminas/diactoros` +- Making the annotation cache directory configurable + +### Miscellaneous + +- Migrated from Travis to Github actions + +## 4.0.0 + +This is a complete refactoring from 3.x. While existing annotations are kept compatible, the internals have completely +changed. + +### New features + +- You can directly [annotate a PHP interface with `@Type` to make it a GraphQL interface](inheritance-interfaces.mdx#mapping-interfaces) +- You can autowire services in resolvers, thanks to the new `@Autowire` annotation +- Added [user input validation](validation.mdx) (using the Symfony Validator or the Laravel validator or a custom `@Assertion` annotation +- Improved security handling: + - Unauthorized access to fields can now generate GraphQL errors (rather that schema errors in GraphQLite v3) + - Added fine-grained security using the `@Security` annotation. A field can now be [marked accessible or not depending on the context](fine-grained-security.mdx). + For instance, you can restrict access to the field "viewsCount" of the type `BlogPost` only for post that the current user wrote. + - You can now inject the current logged user in any query / mutation / field using the `@InjectUser` annotation +- Performance: + - You can inject the [Webonyx query plan in a parameter from a resolver](query-plan.mdx) + - You can use the [dataloader pattern to improve performance drastically via the "prefetchMethod" attribute](prefetch-method.mdx) +- Customizable error handling has been added: + - You can throw [many errors in one exception](error-handling.mdx#many-errors-for-one-exception) with `TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException` +- You can force input types using `@UseInputType(for="$id", inputType="ID!")` +- You can extend an input types (just like you could extend an output type in v3) using [the new `@Decorate` annotation](extend-input-type.mdx) +- In a factory, you can [exclude some optional parameters from the GraphQL schema](input-types#ignoring-some-parameters) + +Many extension points have been added + +- Added a "root type mapper" (useful to map scalar types to PHP types or to add custom annotations related to resolvers) +- Added ["field middlewares"](field-middlewares.md) (useful to add middleware that modify the way GraphQL fields are handled) +- Added a ["parameter type mapper"](argument-resolving.md) (useful to add customize parameter resolution or add custom annotations related to parameters) + +New framework specific features: + +### Symfony + +- The Symfony bundle now provides a "login" and a "logout" mutation (and also a "me" query) + +### Laravel + +- [Native integration with the Laravel paginator](laravel-package-advanced.mdx#support-for-pagination) has been added + +### Internals + +- The `FieldsBuilder` class has been split in many different services (`FieldsBuilder`, `TypeHandler`, and a + chain of *root type mappers*) +- The `FieldsBuilderFactory` class has been completely removed. +- Overall, there is not much in common internally between 4.x and 3.x. 4.x is much more flexible with many more hook points + than 3.x. Try it out! diff --git a/website/versioned_docs/version-7.0.0/README.mdx b/website/versioned_docs/version-7.0.0/README.mdx new file mode 100644 index 0000000000..0293578109 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/README.mdx @@ -0,0 +1,121 @@ +--- +id: index +title: GraphQLite +slug: / +sidebar_label: GraphQLite +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +

+ GraphQLite logo +

+ + +A PHP library that allows you to write your GraphQL queries in simple-to-write controllers. + +## Features + +* Create a complete GraphQL API by simply annotating your PHP classes +* Framework agnostic, but Symfony, Laravel and PSR-15 bindings available! +* Comes with batteries included: queries, mutations, subscriptions, mapping of arrays / iterators, +file uploads, security, validation, extendable types and more! + +## Basic example + +First, declare a query in your controller: + + + + +```php +class ProductController +{ + #[Query] + public function product(string $id): Product + { + // Some code that looks for a product and returns it. + } +} +``` + + + + +```php +class ProductController +{ + /** + * @Query() + */ + public function product(string $id): Product + { + // Some code that looks for a product and returns it. + } +} +``` + + + + +Then, annotate the `Product` class to declare what fields are exposed to the GraphQL API: + + + + +```php +#[Type] +class Product +{ + #[Field] + public function getName(): string + { + return $this->name; + } + // ... +} +``` + + + + +```php +/** + * @Type() + */ +class Product +{ + /** + * @Field() + */ + public function getName(): string + { + return $this->name; + } + // ... +} +``` + + + + +That's it, you're good to go! Query and enjoy! + +```graphql +{ + product(id: 42) { + name + } +} +``` diff --git a/website/versioned_docs/version-7.0.0/annotations-reference.md b/website/versioned_docs/version-7.0.0/annotations-reference.md new file mode 100644 index 0000000000..e838cbf987 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/annotations-reference.md @@ -0,0 +1,320 @@ +--- +id: annotations-reference +title: Annotations reference +sidebar_label: Annotations reference +--- + +Note: all annotations are available both in a Doctrine annotation format (`@Query`) and in PHP 8 attribute format (`#[Query]`). +See [Doctrine annotations vs PHP 8 attributes](doctrine-annotations-attributes.mdx) for more details. + +## @Query + +The `@Query` annotation is used to declare a GraphQL query. + +**Applies on**: controller methods. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +name | *no* | string | The name of the query. If skipped, the name of the method is used instead. +[outputType](custom-types.mdx) | *no* | string | Forces the GraphQL output type of a query. + +## @Mutation + +The `@Mutation` annotation is used to declare a GraphQL mutation. + +**Applies on**: controller methods. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +name | *no* | string | The name of the mutation. If skipped, the name of the method is used instead. +[outputType](custom-types.mdx) | *no* | string | Forces the GraphQL output type of a query. + +## @Subscription + +The `@Subscription` annotation is used to declare a GraphQL subscription. + +**Applies on**: controller methods. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +name | *no* | string | The name of the subscription. If skipped, the name of the method is used instead. +[outputType](custom-types.mdx) | *no* | string | Defines the GraphQL output type that will be sent for the subscription. + +## @Type + +The `@Type` annotation is used to declare a GraphQL object type. This is used with standard output +types, as well as enum types. For input types, use the [@Input annotation](#input-annotation) directly on the input type or a [@Factory annoation](#factory-annotation) to make/return an input type. + +**Applies on**: classes. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +class | *no* | string | The targeted class/enum for the actual type. If no "class" attribute is passed, the type applies to the current class/enum. The current class/enum is assumed to be an entity (not service). If the "class" attribute *is passed*, [the class/enum annotated with `@Type` becomes a service](external-type-declaration.mdx). +name | *no* | string | The name of the GraphQL type generated. If not passed, the name of the class is used. If the class ends with "Type", the "Type" suffix is removed +default | *no* | bool | Defaults to *true*. Whether the targeted PHP class should be mapped by default to this type. +external | *no* | bool | Whether this is an [external type declaration](external-type-declaration.mdx) or not. You usually do not need to use this attribute since this value defaults to true if a "class" attribute is set. This is only useful if you are declaring a type with no PHP class mapping using the "name" attribute. + +## @ExtendType + +The `@ExtendType` annotation is used to add fields to an existing GraphQL object type. + +**Applies on**: classes. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +class | see below | string | The targeted class. [The class annotated with `@ExtendType` is a service](extend-type.mdx). +name | see below | string | The targeted GraphQL output type. + +One and only one of "class" and "name" parameter can be passed at the same time. + +## @Input + +The `@Input` annotation is used to declare a GraphQL input type. + +**Applies on**: classes. + +Attribute | Compulsory | Type | Definition +---------------|------------|--------|-------- +name | *no* | string | The name of the GraphQL input type generated. If not passed, the name of the class with suffix "Input" is used. If the class ends with "Input", the "Input" suffix is not added. +description | *no* | string | Description of the input type in the documentation. If not passed, PHP doc comment is used. +default | *no* | bool | Name of the input type represented in your GraphQL schema. Defaults to `true` *only if* the name is not specified. If `name` is specified, this will default to `false`, so must also be included for `true` when `name` is used. +update | *no* | bool | Determines if the the input represents a partial update. When set to `true` all input fields will become optional and won't have default values thus won't be set on resolve if they are not specified in the query/mutation/subscription. This primarily applies to nullable fields. + +## @Field + +The `@Field` annotation is used to declare a GraphQL field. + +**Applies on**: methods or properties of classes annotated with `@Type`, `@ExtendType` or `@Input`. +When it's applied on private or protected property, public getter or/and setter method is expected in the class accordingly +whether it's used for output type or input type. For example if property name is `foo` then getter should be `getFoo()` or setter should be `setFoo($foo)`. Setter can be omitted if property related to the field is present in the constructor with the same name. + +Attribute | Compulsory | Type | Definition +------------------------------|------------|---------------|-------- +name | *no* | string | The name of the field. If skipped, the name of the method is used instead. +for | *no* | string, array | Forces the field to be used only for specific output or input type(s). By default field is used for all possible declared types. +description | *no* | string | Field description displayed in the GraphQL docs. If it's empty PHP doc comment is used instead. +[outputType](custom-types.mdx) | *no* | string | Forces the GraphQL output type of a query. +[inputType](input-types.mdx) | *no* | string | Forces the GraphQL input type of a query. + +## @SourceField + +The `@SourceField` annotation is used to declare a GraphQL field. + +**Applies on**: classes annotated with `@Type` or `@ExtendType`. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +name | *yes* | string | The name of the field. +[outputType](custom-types.mdx) | *no* | string | Forces the GraphQL output type of the field. Otherwise, return type is used. +phpType | *no* | string | The PHP type of the field (as you would write it in a Docblock) +description | *no* | string | Field description displayed in the GraphQL docs. If it's empty PHP doc comment of the method in the source class is used instead. +sourceName | *no* | string | The name of the property in the source class. If not set, the `name` will be used to get property value. +annotations | *no* | array\ | A set of annotations that apply to this field. You would typically used a "@Logged" or "@Right" annotation here. Available in Doctrine annotations only (not available in the #SourceField PHP 8 attribute) + +**Note**: `outputType` and `phpType` are mutually exclusive. + +## @MagicField + +The `@MagicField` annotation is used to declare a GraphQL field that originates from a PHP magic property (using `__get` magic method). + +**Applies on**: classes annotated with `@Type` or `@ExtendType`. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +name | *yes* | string | The name of the field. +[outputType](custom-types.mdx) | *no*(*) | string | The GraphQL output type of the field. +phpType | *no*(*) | string | The PHP type of the field (as you would write it in a Docblock) +description | *no* | string | Field description displayed in the GraphQL docs. If not set, no description will be shown. +sourceName | *no* | string | The name of the property in the source class. If not set, the `name` will be used to get property value. +annotations | *no* | array\ | A set of annotations that apply to this field. You would typically used a "@Logged" or "@Right" annotation here. Available in Doctrine annotations only (not available in the #MagicField PHP 8 attribute) + +(*) **Note**: `outputType` and `phpType` are mutually exclusive. You MUST provide one of them. + +## @Prefetch + +Marks field parameter to be used for [prefetching](prefetch-method.mdx). + +**Applies on**: parameters of methods annotated with `@Query`, `@Mutation` or `@Field`. + +Attribute | Compulsory | Type | Definition +------------------------------|------------|----------|-------- +callable | *no* | callable | Name of the prefetch method (in same class) or a full callable, either a static method or regular service from the container + + +## @Logged + +The `@Logged` annotation is used to declare a Query/Mutation/Field is only visible to logged users. + +**Applies on**: methods or properties annotated with `@Query`, `@Mutation` or `@Field`. + +This annotation allows no attributes. + +## @Right + +The `@Right` annotation is used to declare a Query/Mutation/Field is only visible to users with a specific right. + +**Applies on**: methods or properties annotated with `@Query`, `@Mutation` or `@Field`. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +name | *yes* | string | The name of the right. + +## @FailWith + +The `@FailWith` annotation is used to declare a default value to return in the user is not authorized to see a specific +query/mutation/subscription/field (according to the `@Logged` and `@Right` annotations). + +**Applies on**: methods or properties annotated with `@Query`, `@Mutation` or `@Field` and one of `@Logged` or `@Right` annotations. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +value | *yes* | mixed | The value to return if the user is not authorized. + +## @HideIfUnauthorized + +
This annotation only works when a Schema is used to handle exactly one use request. +If you serve your GraphQL API from long-running standalone servers (like Laravel Octane, Swoole, RoadRunner etc) and +share the same Schema instance between multiple requests, please avoid using @HideIfUnauthorized.
+ +The `@HideIfUnauthorized` annotation is used to completely hide the query/mutation/subscription/field if the user is not authorized +to access it (according to the `@Logged` and `@Right` annotations). + +**Applies on**: methods or properties annotated with `@Query`, `@Mutation` or `@Field` and one of `@Logged` or `@Right` annotations. + +`@HideIfUnauthorized` and `@FailWith` are mutually exclusive. + +## @InjectUser + +Use the `@InjectUser` annotation to inject an instance of the current user logged in into a parameter of your +query/mutation/subscription/field. + +See [the authentication and authorization page](authentication-authorization.mdx) for more details. + +**Applies on**: methods annotated with `@Query`, `@Mutation` or `@Field`. + +Attribute | Compulsory | Type | Definition +---------------|------------|--------|-------- +*for* | *yes* | string | The name of the PHP parameter + +## @Security + +The `@Security` annotation can be used to check fin-grained access rights. +It is very flexible: it allows you to pass an expression that can contains custom logic. + +See [the fine grained security page](fine-grained-security.mdx) for more details. + +**Applies on**: methods or properties annotated with `@Query`, `@Mutation` or `@Field`. + +Attribute | Compulsory | Type | Definition +---------------|------------|--------|-------- +*default* | *yes* | string | The security expression + +## @Factory + +The `@Factory` annotation is used to declare a factory that turns GraphQL input types into objects. + +**Applies on**: methods from classes in the "types" namespace. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +name | *no* | string | The name of the input type. If skipped, the name of class returned by the factory is used instead. +default | *no* | bool | If `true`, this factory will be used by default for its PHP return type. If set to `false`, you must explicitly [reference this factory using the `@Parameter` annotation](input-types.mdx#declaring-several-input-types-for-the-same-php-class). + +## @UseInputType + +Used to override the GraphQL input type of a PHP parameter. + +**Applies on**: methods annotated with `@Query`, `@Mutation` or `@Field` annotation. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +*for* | *yes* | string | The name of the PHP parameter +*inputType* | *yes* | string | The GraphQL input type to force for this input field + +## @Decorate + +The `@Decorate` annotation is used [to extend/modify/decorate an input type declared with the `@Factory` annotation](extend-input-type.mdx). + +**Applies on**: methods from classes in the "types" namespace. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +name | *yes* | string | The GraphQL input type name extended by this decorator. + +## @Autowire + +[Resolves a PHP parameter from the container](autowiring.mdx). + +Useful to inject services directly into `@Field` method arguments. + +**Applies on**: methods annotated with `@Query`, `@Mutation` or `@Field` annotation. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +*for* | *yes* | string | The name of the PHP parameter +*identifier* | *no* | string | The identifier of the service to fetch. This is optional. Please avoid using this attribute as this leads to a "service locator" anti-pattern. + +## @HideParameter + +Removes [an argument from the GraphQL schema](input-types.mdx#ignoring-some-parameters). + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +*for* | *yes* | string | The name of the PHP parameter to hide + +## @Cost + +Sets complexity and multipliers on fields for [automatic query complexity](operation-complexity.md#static-request-analysis). + +Attribute | Compulsory | Type | Definition +--------------------|------------|-----------------|----------------------------------------------------------------- +*complexity* | *no* | int | Complexity for that field +*multipliers* | *no* | array\ | Names of fields by value of which complexity will be multiplied +*defaultMultiplier* | *no* | int | Default multiplier value if all multipliers are missing/null + +## @Validate + +
This annotation is only available in the GraphQLite Laravel package
+ +[Validates a user input in Laravel](laravel-package-advanced.mdx). + +**Applies on**: methods annotated with `@Query`, `@Mutation`, `@Field`, `@Factory` or `@Decorator` annotation. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +*for* | *yes* | string | The name of the PHP parameter +*rule* | *yes | string | Laravel validation rules + +Sample: + +```php +@Validate(for="$email", rule="email|unique:users") +``` + +## @Assertion + +[Validates a user input](validation.mdx). + +The `@Assertion` annotation is available in the *thecodingmachine/graphqlite-symfony-validator-bridge* third party package. +It is available out of the box if you use the Symfony bundle. + +**Applies on**: methods annotated with `@Query`, `@Mutation`, `@Field`, `@Factory` or `@Decorator` annotation. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +*for* | *yes* | string | The name of the PHP parameter +*constraint* | *yes | annotation | One (or many) Symfony validation annotations. + +## ~~@EnumType~~ + +*Deprecated: Use [PHP 8.1's native Enums](https://www.php.net/manual/en/language.types.enumerations.php) instead with a [@Type](#type-annotation).* + +The `@EnumType` annotation is used to change the name of a "Enum" type. +Note that if you do not want to change the name, the annotation is optionnal. Any object extending `MyCLabs\Enum\Enum` +is automatically mapped to a GraphQL enum type. + +**Applies on**: classes extending the `MyCLabs\Enum\Enum` base class. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +name | *no* | string | The name of the enum type (in the GraphQL schema) diff --git a/website/versioned_docs/version-7.0.0/argument-resolving.md b/website/versioned_docs/version-7.0.0/argument-resolving.md new file mode 100644 index 0000000000..35556c66d0 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/argument-resolving.md @@ -0,0 +1,164 @@ +--- +id: argument-resolving +title: Extending argument resolving +sidebar_label: Custom argument resolving +--- +Available in GraphQLite 4.0+ + +Using a **parameter middleware**, you can hook into the argument resolution of field/query/mutation/factory. + +
Use a parameter middleware if you want to alter the way arguments are injected in a method +or if you want to alter the way input types are imported (for instance if you want to add a validation step)
+ +As an example, GraphQLite uses *parameter middlewares* internally to: + +- Inject the Webonyx GraphQL resolution object when you type-hint on the `ResolveInfo` object. For instance: + + ```php + /** + * @return Product[] + */ + #[Query] + public function products(ResolveInfo $info): array + ``` + + In the query above, the `$info` argument is filled with the Webonyx `ResolveInfo` class thanks to the + [`ResolveInfoParameterHandler parameter middleware`](https://github.com/thecodingmachine/graphqlite/blob/master/src/Mappers/Parameters/ResolveInfoParameterHandler.php) +- Inject a service from the container when you use the `@Autowire` annotation +- Perform validation with the `@Validate` annotation (in Laravel package) + + + +**Parameter middlewares** + + + +Each middleware is passed number of objects describing the parameter: + +- a PHP `ReflectionParameter` object representing the parameter being manipulated +- a `phpDocumentor\Reflection\DocBlock` instance (useful to analyze the `@param` comment if any) +- a `phpDocumentor\Reflection\Type` instance (useful to analyze the type if the argument) +- a `TheCodingMachine\GraphQLite\Annotations\ParameterAnnotations` instance. This is a collection of all custom annotations that apply to this specific argument (more on that later) +- a `$next` handler to pass the argument resolving to the next middleware. + +Parameter resolution is done in 2 passes. + +On the first pass, middlewares are traversed. They must return a `TheCodingMachine\GraphQLite\Parameters\ParameterInterface` (an object that does the actual resolving). + +```php +interface ParameterMiddlewareInterface +{ + public function mapParameter(ReflectionParameter $parameter, DocBlock $docBlock, ?Type $paramTagType, ParameterAnnotations $parameterAnnotations, ParameterHandlerInterface $next): ParameterInterface; +} +``` + +Then, resolution actually happen by executing the resolver (this is the second pass). + +## Annotations parsing + +If you plan to use annotations while resolving arguments, your annotation should extend the [`ParameterAnnotationInterface`](https://github.com/thecodingmachine/graphqlite/blob/master/src/Annotations/ParameterAnnotationInterface.php) + +For instance, if we want GraphQLite to inject a service in an argument, we can use `@Autowire(for="myService")`. + +For PHP 8 attributes, we only need to put declare the annotation can target parameters: `#[Attribute(Attribute::TARGET_PARAMETER)]`. + +The annotation looks like this: + +```php +use Attribute; + +/** + * Use this annotation to autowire a service from the container into a given parameter of a field/query/mutation. + * + * @Annotation + */ +#[Attribute(Attribute::TARGET_PARAMETER)] +class Autowire implements ParameterAnnotationInterface +{ + /** + * @var string + */ + public $for; + + /** + * The getTarget method must return the name of the argument + */ + public function getTarget(): string + { + return $this->for; + } +} +``` + +## Writing the parameter middleware + +The middleware purpose is to analyze a parameter and decide whether or not it can handle it. + +```php title="Parameter middleware class" +class ContainerParameterHandler implements ParameterMiddlewareInterface +{ + /** @var ContainerInterface */ + private $container; + + public function __construct(ContainerInterface $container) + { + $this->container = $container; + } + + public function mapParameter(ReflectionParameter $parameter, DocBlock $docBlock, ?Type $paramTagType, ParameterAnnotations $parameterAnnotations, ParameterHandlerInterface $next): ParameterInterface + { + // The $parameterAnnotations object can be used to fetch any annotation implementing ParameterAnnotationInterface + $autowire = $parameterAnnotations->getAnnotationByType(Autowire::class); + + if ($autowire === null) { + // If there are no annotation, this middleware cannot handle the parameter. Let's ask + // the next middleware in the chain (using the $next object) + return $next->mapParameter($parameter, $docBlock, $paramTagType, $parameterAnnotations); + } + + // We found a @Autowire annotation, let's return a parameter resolver. + return new ContainerParameter($this->container, $parameter->getType()); + } +} +``` + +The last step is to write the actual parameter resolver. + +```php title="Parameter resolver class" +/** + * A parameter filled from the container. + */ +class ContainerParameter implements ParameterInterface +{ + /** @var ContainerInterface */ + private $container; + /** @var string */ + private $identifier; + + public function __construct(ContainerInterface $container, string $identifier) + { + $this->container = $container; + $this->identifier = $identifier; + } + + /** + * The "resolver" returns the actual value that will be fed to the function. + */ + public function resolve(?object $source, array $args, $context, ResolveInfo $info) + { + return $this->container->get($this->identifier); + } +} +``` + +## Registering a parameter middleware + +The last step is to register the parameter middleware we just wrote: + +You can register your own parameter middlewares using the `SchemaFactory::addParameterMiddleware()` method. + +```php +$schemaFactory->addParameterMiddleware(new ContainerParameterHandler($container)); +``` + +If you are using the Symfony bundle, you can tag the service as "graphql.parameter_middleware". diff --git a/website/versioned_docs/version-7.0.0/authentication-authorization.mdx b/website/versioned_docs/version-7.0.0/authentication-authorization.mdx new file mode 100644 index 0000000000..f716d02d30 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/authentication-authorization.mdx @@ -0,0 +1,301 @@ +--- +id: authentication-authorization +title: Authentication and authorization +sidebar_label: Authentication and authorization +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +You might not want to expose your GraphQL API to anyone. Or you might want to keep some +queries/mutations/subscriptions or fields reserved to some users. + +GraphQLite offers some control over what a user can do with your API. You can restrict access to +resources: + +- based on authentication using the [`@Logged` annotation](#logged-and-right-annotations) (restrict access to logged users) +- based on authorization using the [`@Right` annotation](#logged-and-right-annotations) (restrict access to logged users with certain rights). +- based on fine-grained authorization using the [`@Security` annotation](fine-grained-security.mdx) (restrict access for some given resources to some users). + +
+ GraphQLite does not have its own security mechanism. + Unless you're using our Symfony Bundle or our Laravel package, it is up to you to connect this feature to your framework's security mechanism.
+ See Connecting GraphQLite to your framework's security module. +
+ +## `@Logged` and `@Right` annotations + +GraphQLite exposes two annotations (`@Logged` and `@Right`) that you can use to restrict access to a resource. + + + +```php +namespace App\Controller; + +use TheCodingMachine\GraphQLite\Annotations\Query; +use TheCodingMachine\GraphQLite\Annotations\Logged; +use TheCodingMachine\GraphQLite\Annotations\Right; + +class UserController +{ + /** + * @return User[] + */ + #[Query] + #[Logged] + #[Right("CAN_VIEW_USER_LIST")] + public function users(int $limit, int $offset): array + { + // ... + } +} +``` + + + + +```php +namespace App\Controller; + +use TheCodingMachine\GraphQLite\Annotations\Query; +use TheCodingMachine\GraphQLite\Annotations\Logged; +use TheCodingMachine\GraphQLite\Annotations\Right; + +class UserController +{ + /** + * @Query + * @Logged + * @Right("CAN_VIEW_USER_LIST") + * @return User[] + */ + public function users(int $limit, int $offset): array + { + // ... + } +} +``` + + + + + +In the example above, the query `users` will only be available if the user making the query is logged AND if he +has the `CAN_VIEW_USER_LIST` right. + +`@Logged` and `@Right` annotations can be used next to: + +* `@Query` annotations +* `@Mutation` annotations +* `@Field` annotations + +
+ By default, if a user tries to access an unauthorized query/mutation/subscription/field, an error is + raised and the query fails. +
+ +## Not throwing errors + +If you do not want an error to be thrown when a user attempts to query a field/query/mutation/subscription +they have no access to, you can use the `@FailWith` annotation. + +The `@FailWith` annotation contains the value that will be returned for users with insufficient rights. + + + + +```php +class UserController +{ + /** + * If a user is not logged or if the user has not the right "CAN_VIEW_USER_LIST", + * the value returned will be "null". + * + * @return User[] + */ + #[Query] + #[Logged] + #[Right("CAN_VIEW_USER_LIST")] + #[FailWith(value: null)] + public function users(int $limit, int $offset): array + { + // ... + } +} +``` + + + + +```php +class UserController +{ + /** + * If a user is not logged or if the user has not the right "CAN_VIEW_USER_LIST", + * the value returned will be "null". + * + * @Query + * @Logged + * @Right("CAN_VIEW_USER_LIST") + * @FailWith(null) + * @return User[] + */ + public function users(int $limit, int $offset): array + { + // ... + } +} +``` + + + + +## Injecting the current user as a parameter + +Use the `@InjectUser` annotation to get an instance of the current user logged in. + + + + +```php +namespace App\Controller; + +use TheCodingMachine\GraphQLite\Annotations\Query; +use TheCodingMachine\GraphQLite\Annotations\InjectUser; + +class ProductController +{ + /** + * @Query + * @return Product + */ + public function product( + int $id, + #[InjectUser] + User $user + ): Product + { + // ... + } +} +``` + + + + +```php +namespace App\Controller; + +use TheCodingMachine\GraphQLite\Annotations\Query; +use TheCodingMachine\GraphQLite\Annotations\InjectUser; + +class ProductController +{ + /** + * @Query + * @InjectUser(for="$user") + * @return Product + */ + public function product(int $id, User $user): Product + { + // ... + } +} +``` + + + + +The `@InjectUser` annotation can be used next to: + +* `@Query` annotations +* `@Mutation` annotations +* `@Field` annotations + +The object injected as the current user depends on your framework. It is in fact the object returned by the +["authentication service" configured in GraphQLite](implementing-security.md). If user is not authenticated and +parameter's type is not nullable, an authorization exception is thrown, similar to `@Logged` annotation. + +## Hiding fields / queries / mutations / subscriptions + +By default, a user analysing the GraphQL schema can see all queries/mutations/subscriptions/types available. +Some will be available to him and some won't. + +If you want to add an extra level of security (or if you want your schema to be kept secret to unauthorized users), +you can use the `@HideIfUnauthorized` annotation. Beware of [it's limitations](annotations-reference.md). + + + + +```php +class UserController +{ + /** + * If a user is not logged or if the user has not the right "CAN_VIEW_USER_LIST", + * the schema will NOT contain the "users" query at all (so trying to call the + * "users" query will result in a GraphQL "query not found" error. + * + * @return User[] + */ + #[Query] + #[Logged] + #[Right("CAN_VIEW_USER_LIST")] + #[HideIfUnauthorized] + public function users(int $limit, int $offset): array + { + // ... + } +} +``` + + + + +```php +class UserController +{ + /** + * If a user is not logged or if the user has not the right "CAN_VIEW_USER_LIST", + * the schema will NOT contain the "users" query at all (so trying to call the + * "users" query will result in a GraphQL "query not found" error. + * + * @Query + * @Logged + * @Right("CAN_VIEW_USER_LIST") + * @HideIfUnauthorized() + * @return User[] + */ + public function users(int $limit, int $offset): array + { + // ... + } +} +``` + + + + +While this is the most secured mode, it can have drawbacks when working with development tools +(you need to be logged as admin to fetch the complete schema). + +
The "HideIfUnauthorized" mode was the default mode in GraphQLite 3 and is optional from GraphQLite 4+.
diff --git a/website/versioned_docs/version-7.0.0/automatic-persisted-queries.mdx b/website/versioned_docs/version-7.0.0/automatic-persisted-queries.mdx new file mode 100644 index 0000000000..f505a869f4 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/automatic-persisted-queries.mdx @@ -0,0 +1,61 @@ +--- +id: automatic-persisted-queries +title: Automatic persisted queries +sidebar_label: Automatic persisted queries +--- + +## The problem + +Clients send queries to GraphQLite as HTTP requests that include the GraphQL string of the query to execute. +Depending on your graph's schema, the size of a valid query string might be arbitrarily large. +As query strings become larger, increased latency and network usage can noticeably degrade client performance. + +To combat this, GraphQL servers use a technique called "persisted queries". The basic idea is instead of +sending the whole query string, clients only send it's unique identifier. The server then finds the actual +query string by given identifier and use that as if the client sent the whole query in the first place. +That helps improve GraphQL network performance with zero build-time configuration by sending smaller GraphQL HTTP requests. +A smaller request payload reduces bandwidth utilization and speeds up GraphQL Client loading times. + +## Apollo APQ + +[Automatic persisted queries (APQ) is technique created by Apollo](https://www.apollographql.com/docs/apollo-server/performance/apq/) +and is aimed to implement a simple automatic way of persisting queries. Queries are cached on the server side, +along with its unique identifier (always its SHA-256 hash). Clients can send this identifier instead of the +corresponding query string, thus reducing request sizes dramatically (response sizes are unaffected). + +To persist a query string, GraphQLite server must first receive it from a requesting client. +Consequently, each unique query string must be sent to Apollo Server at least once. +After any client sends a query string to persist, every client that executes that query can then benefit from APQ. + +```mermaid +sequenceDiagram; + Client app->>GraphQL Server: Sends SHA-256 hash of query string to execute + Note over GraphQL Server: Fails to find persisted query string + GraphQL Server->>Client app: Responds with error + Client app->>GraphQL Server: Sends both query string AND hash + Note over GraphQL Server: Persists query string and hash + GraphQL Server->>Client app: Executes query and returns result + Note over Client app: Time passes + Client app->>GraphQL Server: Sends SHA-256 hash of query string to execute + Note over GraphQL Server: Finds persisted query string + GraphQL Server->>Client app: Executes query and returns result +``` + +Persisted queries are especially effective when clients send queries as GET requests. +This enables clients to take advantage of the browser cache and integrate with a CDN. + +Because query identifiers are deterministic hashes, clients can generate them at runtime. No additional build steps are required. + +## Setup + +To use Automatic persisted queries with GraphQLite, you may use +`useAutomaticPersistedQueries` method when building your PSR-15 middleware: + +```php +$builder = new Psr15GraphQLMiddlewareBuilder($schema); + +// You need to provide a PSR compatible cache and a TTL for queries. The best cache would be some kind +// of in-memory cache with a limit on number of entries to make sure your cache can't be maliciously spammed with queries. +$builder->useAutomaticPersistedQueries($cache, new DateInterval('PT1H')); +``` + diff --git a/website/versioned_docs/version-7.0.0/autowiring.mdx b/website/versioned_docs/version-7.0.0/autowiring.mdx new file mode 100644 index 0000000000..c38bb73d77 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/autowiring.mdx @@ -0,0 +1,171 @@ +--- +id: autowiring +title: Autowiring services +sidebar_label: Autowiring services +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +GraphQLite can automatically inject services in your fields/queries/mutations signatures. + +Some of your fields may be computed. In order to compute these fields, you might need to call a service. + +Most of the time, your `@Type` annotation will be put on a model. And models do not have access to services. +Hopefully, if you add a type-hinted service in your field's declaration, GraphQLite will automatically fill it with +the service instance. + +## Sample + +Let's assume you are running an international store. You have a `Product` class. Each product has many names (depending +on the language of the user). + + + + +```php +namespace App\Entities; + +use TheCodingMachine\GraphQLite\Annotations\Autowire; +use TheCodingMachine\GraphQLite\Annotations\Field; +use TheCodingMachine\GraphQLite\Annotations\Type; + +use Symfony\Component\Translation\TranslatorInterface; + +#[Type] +class Product +{ + // ... + + #[Field] + public function getName( + #[Autowire] + TranslatorInterface $translator + ): string + { + return $translator->trans('product_name_'.$this->id); + } +} +``` + + + + +```php +namespace App\Entities; + +use TheCodingMachine\GraphQLite\Annotations\Autowire; +use TheCodingMachine\GraphQLite\Annotations\Field; +use TheCodingMachine\GraphQLite\Annotations\Type; + +use Symfony\Component\Translation\TranslatorInterface; + +/** + * @Type() + */ +class Product +{ + // ... + + /** + * @Field() + * @Autowire(for="$translator") + */ + public function getName(TranslatorInterface $translator): string + { + return $translator->trans('product_name_'.$this->id); + } +} +``` + + + + +When GraphQLite queries the name, it will automatically fetch the translator service. + +
As with most autowiring solutions, GraphQLite assumes that the service identifier +in the container is the fully qualified class name of the type-hint. So in the example above, GraphQLite will +look for a service whose name is Symfony\Component\Translation\TranslatorInterface.
+ +## Best practices + +It is a good idea to refrain from type-hinting on concrete implementations. +Most often, your field declaration will be in your model. If you add a type-hint on a service, you are binding your domain +with a particular service implementation. This makes your code tightly coupled and less testable. + +
+Please don't do that: + +

+    #[Field]
+    public function getName(#[Autowire] MyTranslator $translator): string
+    {
+        // Your domain is suddenly tightly coupled to the MyTranslator class.
+    }
+
+
+ +Instead, be sure to type-hint against an interface. + +
+Do this instead: + +

+    #[Field]
+    public function getName(#[Autowire] TranslatorInterface $translator): string
+    {
+        // Good. You can switch translator implementation any time.
+    }
+
+
+ +By type-hinting against an interface, your code remains testable and is decoupled from the service implementation. + +## Fetching a service by name (discouraged!) + +Optionally, you can specify the identifier of the service you want to fetch from the controller: + + + + +```php +#[Autowire(identifier: "translator")] +``` + + + + +```php +/** + * @Autowire(for="$translator", identifier="translator") + */ +``` + + + + +
While GraphQLite offers the possibility to specify the name of the service to be +autowired, we would like to emphasize that this is highly discouraged. Hard-coding a container +identifier in the code of your class is akin to using the "service locator" pattern, which is known to be an +anti-pattern. Please refrain from doing this as much as possible.
+ +## Alternative solution + +You may find yourself uncomfortable with the autowiring mechanism of GraphQLite. For instance maybe: + +- Your service identifier in the container is not the fully qualified class name of the service (this is often true if you are not using a container supporting autowiring) +- You do not want to inject a service in a domain object +- You simply do not like the magic of injecting services in a method signature + +If you do not want to use autowiring and if you still need to access services to compute a field, please read on +the next chapter to learn [how to extend a type](extend-type). diff --git a/website/versioned_docs/version-7.0.0/custom-types.mdx b/website/versioned_docs/version-7.0.0/custom-types.mdx new file mode 100644 index 0000000000..800ab2846c --- /dev/null +++ b/website/versioned_docs/version-7.0.0/custom-types.mdx @@ -0,0 +1,272 @@ +--- +id: custom-types +title: Custom types +sidebar_label: Custom types +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +In some special cases, you want to override the GraphQL return type that is attributed by default by GraphQLite. + +For instance: + + + + +```php +#[Type(class: Product::class)] +class ProductType +{ + #[Field] + public function getId(Product $source): string + { + return $source->getId(); + } +} +``` + + + + +```php +/** + * @Type(class=Product::class) + */ +class ProductType +{ + /** + * @Field + */ + public function getId(Product $source): string + { + return $source->getId(); + } +} +``` + + + + +In the example above, GraphQLite will generate a GraphQL schema with a field `id` of type `string`: + +```graphql +type Product { + id: String! +} +``` + +GraphQL comes with an `ID` scalar type. But PHP has no such type. So GraphQLite does not know when a variable +is an `ID` or not. + +You can help GraphQLite by manually specifying the output type to use: + + + + +```php + #[Field(outputType: "ID")] +``` + + + + +```php + /** + * @Field(name="id", outputType="ID") + */ +``` + + + + +## Usage + +The `outputType` attribute will map the return value of the method to the output type passed in parameter. + +You can use the `outputType` attribute in the following annotations: + +* `@Query` +* `@Mutation` +* `@Subscription` +* `@Field` +* `@SourceField` +* `@MagicField` + +## Registering a custom output type (advanced) + +In order to create a custom output type, you need to: + +1. Design a class that extends `GraphQL\Type\Definition\ObjectType`. +2. Register this class in the GraphQL schema. + +You'll find more details on the [Webonyx documentation](https://webonyx.github.io/graphql-php/type-system/object-types/). + +--- + +In order to find existing types, the schema is using *type mappers* (classes implementing the `TypeMapperInterface` interface). + +You need to make sure that one of these type mappers can return an instance of your type. The way you do this will depend on the framework +you use. + +### Symfony users + +Any class extending `GraphQL\Type\Definition\ObjectType` (and available in the container) will be automatically detected +by Symfony and added to the schema. + +If you want to automatically map the output type to a given PHP class, you will have to explicitly declare the output type +as a service and use the `graphql.output_type` tag: + +```yaml +# config/services.yaml +services: + App\MyOutputType: + tags: + - { name: 'graphql.output_type', class: 'App\MyPhpClass' } +``` + +### Other frameworks + +The easiest way is to use a `StaticTypeMapper`. Use this class to register custom output types. + +```php +// Sample code: +$staticTypeMapper = new StaticTypeMapper( + // Let's register a type that maps by default to the "MyClass" PHP class + types: [ + MyClass::class => new MyCustomOutputType() + ], + + // If you don't want your output type to map to any PHP class by default, use: + notMappedTypes: [ + new MyCustomOutputType() + ], +); + +// Register the static type mapper in your application using the SchemaFactory instance +$schemaFactory->addTypeMapper($staticTypeMapper); +``` + +## Registering a custom scalar type (advanced) + +If you need to add custom scalar types, first, check the [GraphQLite Misc. Types library](https://github.com/thecodingmachine/graphqlite-misc-types). +It contains a number of "out-of-the-box" scalar types ready to use and you might find what you need there. + +You still need to develop your custom scalar type? Ok, let's get started. + +In order to add a scalar type in GraphQLite, you need to: + +- create a [Webonyx custom scalar type](https://webonyx.github.io/graphql-php/type-system/scalar-types/#writing-custom-scalar-types). + You do this by creating a class that extends `GraphQL\Type\Definition\ScalarType`. +- create a "type mapper" that will map PHP types to the GraphQL scalar type. You do this by writing a class implementing the `RootTypeMapperInterface`. +- create a "type mapper factory" that will be in charge of creating your "type mapper". + +```php +interface RootTypeMapperInterface +{ + /** + * @param \ReflectionMethod|\ReflectionProperty $reflector + */ + public function toGraphQLOutputType(Type $type, ?OutputType $subType, $reflector, DocBlock $docBlockObj): OutputType; + + /** + * @param \ReflectionMethod|\ReflectionProperty $reflector + */ + public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, $reflector, DocBlock $docBlockObj): InputType; + + public function mapNameToType(string $typeName): NamedType; +} +``` + +The `toGraphQLOutputType` and `toGraphQLInputType` are meant to map a return type (for output types) or a parameter type (for input types) +to your GraphQL scalar type. Return your scalar type if there is a match or `null` if there no match. + +The `mapNameToType` should return your GraphQL scalar type if `$typeName` is the name of your scalar type. + +RootTypeMapper are organized **in a chain** (they are actually middlewares). +Each instance of a `RootTypeMapper` holds a reference on the next root type mapper to be called in the chain. + +For instance: + +```php +class AnyScalarTypeMapper implements RootTypeMapperInterface +{ + /** @var RootTypeMapperInterface */ + private $next; + + public function __construct(RootTypeMapperInterface $next) + { + $this->next = $next; + } + + public function toGraphQLOutputType(Type $type, ?OutputType $subType, ReflectionMethod $refMethod, DocBlock $docBlockObj): ?OutputType + { + if ($type instanceof Scalar) { + // AnyScalarType is a class implementing the Webonyx ScalarType type. + return AnyScalarType::getInstance(); + } + // If the PHPDoc type is not "Scalar", let's pass the control to the next type mapper in the chain + return $this->next->toGraphQLOutputType($type, $subType, $refMethod, $docBlockObj); + } + + public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, ReflectionMethod $refMethod, DocBlock $docBlockObj): ?InputType + { + if ($type instanceof Scalar) { + // AnyScalarType is a class implementing the Webonyx ScalarType type. + return AnyScalarType::getInstance(); + } + // If the PHPDoc type is not "Scalar", let's pass the control to the next type mapper in the chain + return $this->next->toGraphQLInputType($type, $subType, $argumentName, $refMethod, $docBlockObj); + } + + /** + * Returns a GraphQL type by name. + * If this root type mapper can return this type in "toGraphQLOutputType" or "toGraphQLInputType", it should + * also map these types by name in the "mapNameToType" method. + * + * @param string $typeName The name of the GraphQL type + * @return NamedType|null + */ + public function mapNameToType(string $typeName): ?NamedType + { + if ($typeName === AnyScalarType::NAME) { + return AnyScalarType::getInstance(); + } + return null; + } +} +``` + +Now, in order to create an instance of your `AnyScalarTypeMapper` class, you need an instance of the `$next` type mapper in the chain. +How do you get the `$next` type mapper? Through a factory: + +```php +class AnyScalarTypeMapperFactory implements RootTypeMapperFactoryInterface +{ + public function create(RootTypeMapperInterface $next, RootTypeMapperFactoryContext $context): RootTypeMapperInterface + { + return new AnyScalarTypeMapper($next); + } +} +``` + +Now, you need to register this factory in your application, and we are done. + +You can register your own root mapper factories using the `SchemaFactory::addRootTypeMapperFactory()` method. + +```php +$schemaFactory->addRootTypeMapperFactory(new AnyScalarTypeMapperFactory()); +``` + +If you are using the Symfony bundle, the factory will be automatically registered, you have nothing to do (the service +is automatically tagged with the "graphql.root_type_mapper_factory" tag). diff --git a/website/versioned_docs/version-7.0.0/doctrine-annotations-attributes.mdx b/website/versioned_docs/version-7.0.0/doctrine-annotations-attributes.mdx new file mode 100644 index 0000000000..72725a39b2 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/doctrine-annotations-attributes.mdx @@ -0,0 +1,164 @@ +--- +id: doctrine-annotations-attributes +title: Doctrine annotations VS PHP8 attributes +sidebar_label: Annotations VS Attributes +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +GraphQLite is heavily relying on the concept of annotations (also called attributes in PHP 8+). + +## Doctrine annotations + +
+ Deprecated! Doctrine annotations are deprecated in favor of native PHP 8 attributes. Support will be dropped in a future release. +
+ +Historically, attributes were not available in PHP and PHP developers had to "trick" PHP to get annotation support. This was the purpose of the [doctrine/annotation](https://www.doctrine-project.org/projects/doctrine-annotations/en/latest/index.html) library. + +Using Doctrine annotations, you write annotations in your docblocks: + +```php +use TheCodingMachine\GraphQLite\Annotations\Type; + +/** + * @Type + */ +class MyType +{ +} +``` + +Please note that: + +- The annotation is added in a **docblock** (a comment starting with "`/**`") +- The `Type` part is actually a class. It must be declared in the `use` statements at the top of your file. + + +
+ Heads up! +

Some IDEs provide support for Doctrine annotations:

+ + + We strongly recommend using an IDE that has Doctrine annotations support. +
+ +## PHP 8 attributes + +Starting with PHP 8, PHP got native annotations support. They are actually called "attributes" in the PHP world. + +The same code can be written this way: + +```php +use TheCodingMachine\GraphQLite\Annotations\Type; + +#[Type] +class MyType +{ +} +``` + +GraphQLite v4.1+ has support for PHP 8 attributes. + +The Doctrine annotation class and the PHP 8 attribute class is **the same** (so you will be using the same `use` statement at the top of your file). + +They support the same attributes too. + +A few notable differences: + +- PHP 8 attributes do not support nested attributes (unlike Doctrine annotations). This means there is no equivalent to the `annotations` attribute of `@MagicField` and `@SourceField`. +- PHP 8 attributes can be written at the parameter level. Any attribute targeting a "parameter" must be written at the parameter level. + +Let's take an example with the [`#Autowire` attribute](autowiring.mdx): + + + + +```php +#[Field] +public function getProduct(#[Autowire] ProductRepository $productRepository) : Product { + //... +} +``` + + + + +```php +/** + * @Field + * @Autowire(for="$productRepository") + */ +public function getProduct(ProductRepository $productRepository) : Product { + //... +} +``` + + + + + +## Migrating from Doctrine annotations to PHP 8 attributes + +The good news is that you can easily migrate from Doctrine annotations to PHP 8 attributes using the amazing, [Rector library](https://github.com/rectorphp/rector). To do so, you'll want to use the following rector configuration: + +```php title="rector.php" +import(SetList::CODE_QUALITY); + + // Set parameters + $parameters = $containerConfigurator->parameters(); + $parameters->set(Option::PATHS, [ + __DIR__ . '/src', + __DIR__ . '/tests', + ]); + + $services = $containerConfigurator->services(); + + // @Validate and @Assertion are part of other libraries, include if necessary + $services->set(AnnotationToAttributeRector::class) + ->configure([ + new AnnotationToAttribute(GraphQLite\Query::class), + new AnnotationToAttribute(GraphQLite\Mutation::class), + new AnnotationToAttribute(GraphQLite\Type::class), + new AnnotationToAttribute(GraphQLite\ExtendType::class), + new AnnotationToAttribute(GraphQLite\Input::class), + new AnnotationToAttribute(GraphQLite\Field::class), + new AnnotationToAttribute(GraphQLite\SourceField::class), + new AnnotationToAttribute(GraphQLite\MagicField::class), + new AnnotationToAttribute(GraphQLite\Logged::class), + new AnnotationToAttribute(GraphQLite\Right::class), + new AnnotationToAttribute(GraphQLite\FailWith::class), + new AnnotationToAttribute(GraphQLite\HideIfUnauthorized::class), + new AnnotationToAttribute(GraphQLite\InjectUser::class), + new AnnotationToAttribute(GraphQLite\Security::class), + new AnnotationToAttribute(GraphQLite\Factory::class), + new AnnotationToAttribute(GraphQLite\UseInputType::class), + new AnnotationToAttribute(GraphQLite\Decorate::class), + new AnnotationToAttribute(GraphQLite\Autowire::class), + new AnnotationToAttribute(GraphQLite\HideParameter::class), + new AnnotationToAttribute(GraphQLite\EnumType::class), + ]); +}; +``` diff --git a/website/versioned_docs/version-7.0.0/error-handling.mdx b/website/versioned_docs/version-7.0.0/error-handling.mdx new file mode 100644 index 0000000000..231f07db72 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/error-handling.mdx @@ -0,0 +1,222 @@ +--- +id: error-handling +title: Error handling +sidebar_label: Error handling +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +In GraphQL, when an error occurs, the server must add an "error" entry in the response. + +```json +{ + "errors": [ + { + "message": "Name for character with ID 1002 could not be fetched.", + "locations": [ { "line": 6, "column": 7 } ], + "path": [ "hero", "heroFriends", 1, "name" ], + "extensions": { + "category": "Exception" + } + } + ] +} +``` + +You can generate such errors with GraphQLite by throwing a `GraphQLException`. + +```php +use TheCodingMachine\GraphQLite\Exceptions\GraphQLException; + +throw new GraphQLException("Exception message"); +``` + +## HTTP response code + +By default, when you throw a `GraphQLException`, the HTTP status code will be 500. + +If your exception code is in the 4xx - 5xx range, the exception code will be used as an HTTP status code. + +```php +// This exception will generate a HTTP 404 status code +throw new GraphQLException("Not found", 404); +``` + +
GraphQL allows to have several errors for one request. If you have several +GraphQLException thrown for the same request, the HTTP status code used will be the highest one.
+ +## Customizing the category + +By default, GraphQLite adds a "category" entry in the "extensions section". You can customize the category with the +4th parameter of the constructor: + +```php +throw new GraphQLException("Not found", 404, null, "NOT_FOUND"); +``` + +will generate: + +```json +{ + "errors": [ + { + "message": "Not found", + "extensions": { + "category": "NOT_FOUND" + } + } + ] +} +``` + +## Customizing the extensions section + +You can customize the whole "extensions" section with the 5th parameter of the constructor: + +```php +throw new GraphQLException("Field required", 400, null, "VALIDATION", ['field' => 'name']); +``` + +will generate: + +```json +{ + "errors": [ + { + "message": "Field required", + "extensions": { + "category": "VALIDATION", + "field": "name" + } + } + ] +} +``` + +## Writing your own exceptions + +Rather that throwing the base `GraphQLException`, you should consider writing your own exception. + +Any exception that implements interface `TheCodingMachine\GraphQLite\Exceptions\GraphQLExceptionInterface` will be displayed +in the GraphQL "errors" section. + +```php +class ValidationException extends Exception implements GraphQLExceptionInterface +{ + /** + * Returns true when exception message is safe to be displayed to a client. + */ + public function isClientSafe(): bool + { + return true; + } + + /** + * Returns string describing a category of the error. + * + * Value "graphql" is reserved for errors produced by query parsing or validation, do not use it. + */ + public function getCategory(): string + { + return 'VALIDATION'; + } + + /** + * Returns the "extensions" object attached to the GraphQL error. + * + * @return array + */ + public function getExtensions(): array + { + return []; + } +} +``` + +## Many errors for one exception + +Sometimes, you need to display several errors in the response. But of course, at any given point in your code, you can +throw only one exception. + +If you want to display several exceptions, you can bundle these exceptions in a `GraphQLAggregateException` that you can +throw. + + + + +```php +use TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException; + +#[Query] +public function createProduct(string $name, float $price): Product +{ + $exceptions = new GraphQLAggregateException(); + + if ($name === '') { + $exceptions->add(new GraphQLException('Name cannot be empty', 400, null, 'VALIDATION')); + } + if ($price <= 0) { + $exceptions->add(new GraphQLException('Price must be positive', 400, null, 'VALIDATION')); + } + + if ($exceptions->hasExceptions()) { + throw $exceptions; + } +} +``` + + + + +```php +use TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException; + +/** + * @Query + */ +public function createProduct(string $name, float $price): Product +{ + $exceptions = new GraphQLAggregateException(); + + if ($name === '') { + $exceptions->add(new GraphQLException('Name cannot be empty', 400, null, 'VALIDATION')); + } + if ($price <= 0) { + $exceptions->add(new GraphQLException('Price must be positive', 400, null, 'VALIDATION')); + } + + if ($exceptions->hasExceptions()) { + throw $exceptions; + } +} +``` + + + + +## Webonyx exceptions + +GraphQLite is based on the wonderful webonyx/GraphQL-PHP library. Therefore, the Webonyx exception mechanism can +also be used in GraphQLite. This means you can throw a `GraphQL\Error\Error` exception or any exception implementing +[`GraphQL\Error\ClientAware` interface](http://webonyx.github.io/graphql-php/error-handling/#errors-in-graphql) + +Actually, the `TheCodingMachine\GraphQLite\Exceptions\GraphQLExceptionInterface` extends Webonyx's `ClientAware` interface. + +## Behaviour of exceptions that do not implement ClientAware + +If an exception that does not implement `ClientAware` is thrown, by default, GraphQLite will not catch it. + +The exception will propagate to your framework error handler/middleware that is in charge of displaying the classical error page. + +You can [change the underlying behaviour of Webonyx to catch any exception and turn them into GraphQL errors](http://webonyx.github.io/graphql-php/error-handling/#debugging-tools). +The way you adjust the error settings depends on the framework you are using ([Symfony](symfony-bundle.md), [Laravel](laravel-package.md)). + +
To be clear: we strongly discourage changing this setting. We strongly believe that the +default "RETHROW_UNSAFE_EXCEPTIONS" setting of Webonyx is the only sane setting (only putting in "errors" section exceptions +designed for GraphQL).
diff --git a/website/versioned_docs/version-7.0.0/extend-input-type.mdx b/website/versioned_docs/version-7.0.0/extend-input-type.mdx new file mode 100644 index 0000000000..767856e32f --- /dev/null +++ b/website/versioned_docs/version-7.0.0/extend-input-type.mdx @@ -0,0 +1,136 @@ +--- +id: extend-input-type +title: Extending an input type +sidebar_label: Extending an input type +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +Available in GraphQLite 4.0+ + +
If you are not familiar with the @Factory tag, read first the "input types" guide.
+ +Fields exposed in a GraphQL input type do not need to be all part of the factory method. + +Just like with output type (that can be [extended using the `ExtendType` annotation](extend-type.mdx)), you can extend/modify +an input type using the `@Decorate` annotation. + +Use the `@Decorate` annotation to add additional fields to an input type that is already declared by a `@Factory` annotation, +or to modify the returned object. + +
+ The @Decorate annotation is very useful in scenarios where you cannot touch the @Factory method. + This can happen if the @Factory method is defined in a third-party library or if the @Factory method is part + of auto-generated code. +
+ +Let's assume you have a `Filter` class used as an input type. You most certainly have a `@Factory` to create the input type. + + + + +```php +class MyFactory +{ + #[Factory] + public function createFilter(string $name): Filter + { + // Let's assume you have a flexible 'Filter' class that can accept any kind of filter + $filter = new Filter(); + $filter->addFilter('name', $name); + return $filter; + } +} +``` + + + + +```php +class MyFactory +{ + /** + * @Factory() + */ + public function createFilter(string $name): Filter + { + // Let's assume you have a flexible 'Filter' class that can accept any kind of filter + $filter = new Filter(); + $filter->addFilter('name', $name); + return $filter; + } +} +``` + + + + +Assuming you **cannot** modify the code of this factory, you can still modify the GraphQL input type generated by +adding a "decorator" around the factory. + + + + +```php +class MyDecorator +{ + #[Decorate(inputTypeName: "FilterInput")] + public function addTypeFilter(Filter $filter, string $type): Filter + { + $filter->addFilter('type', $type); + return $filter; + } +} +``` + + + + +```php +class MyDecorator +{ + /** + * @Decorate(inputTypeName="FilterInput") + */ + public function addTypeFilter(Filter $filter, string $type): Filter + { + $filter->addFilter('type', $type); + return $filter; + } +} +``` + + + + +In the example above, the "Filter" input type is modified. We add an additional "type" field to the input type. + +A few things to notice: + +- The decorator takes the object generated by the factory as first argument +- The decorator MUST return an object of the same type (or a sub-type) +- The decorator CAN contain additional parameters. They will be added to the fields of the GraphQL input type. +- The `@Decorate` annotation must contain a `inputTypeName` attribute that contains the name of the GraphQL input type + that is decorated. If you did not specify this name in the `@Factory` annotation, this is by default the name of the + PHP class + "Input" (for instance: "Filter" => "FilterInput") + + +
+ Heads up! The MyDecorator class must exist in the container of your + application and the container identifier MUST be the fully qualified class name. +

+ If you are using the Symfony bundle (or a framework with autowiring like Laravel), this is usually + not an issue as the container will automatically create the controller entry if you do not explicitly + declare it. +
diff --git a/website/versioned_docs/version-7.0.0/extend-type.mdx b/website/versioned_docs/version-7.0.0/extend-type.mdx new file mode 100644 index 0000000000..c8efb8d920 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/extend-type.mdx @@ -0,0 +1,269 @@ +--- +id: extend-type +title: Extending a type +sidebar_label: Extending a type +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +Fields exposed in a GraphQL type do not need to be all part of the same class. + +Use the `@ExtendType` annotation to add additional fields to a type that is already declared. + +
+ Extending a type has nothing to do with type inheritance. + If you are looking for a way to expose a class and its children classes, have a look at + the Inheritance section +
+ +Let's assume you have a `Product` class. In order to get the name of a product, there is no `getName()` method in +the product because the name needs to be translated in the correct language. You have a `TranslationService` to do that. + + + + +```php +namespace App\Entities; + +use TheCodingMachine\GraphQLite\Annotations\Field; +use TheCodingMachine\GraphQLite\Annotations\Type; + +#[Type] +class Product +{ + // ... + + #[Field] + public function getId(): string + { + return $this->id; + } + + #[Field] + public function getPrice(): ?float + { + return $this->price; + } +} +``` + + + + +```php +namespace App\Entities; + +use TheCodingMachine\GraphQLite\Annotations\Field; +use TheCodingMachine\GraphQLite\Annotations\Type; + +/** + * @Type() + */ +class Product +{ + // ... + + /** + * @Field() + */ + public function getId(): string + { + return $this->id; + } + + /** + * @Field() + */ + public function getPrice(): ?float + { + return $this->price; + } +} +``` + + + + +```php +// You need to use a service to get the name of the product in the correct language. +$name = $translationService->getProductName($productId, $language); +``` + +Using `@ExtendType`, you can add an additional `name` field to your product: + + + + +```php +namespace App\Types; + +use TheCodingMachine\GraphQLite\Annotations\ExtendType; +use TheCodingMachine\GraphQLite\Annotations\Field; +use App\Entities\Product; + +#[ExtendType(class: Product::class)] +class ProductType +{ + private $translationService; + + public function __construct(TranslationServiceInterface $translationService) + { + $this->translationService = $translationService; + } + + #[Field] + public function getName(Product $product, string $language): string + { + return $this->translationService->getProductName($product->getId(), $language); + } +} +``` + + + + +```php +namespace App\Types; + +use TheCodingMachine\GraphQLite\Annotations\ExtendType; +use TheCodingMachine\GraphQLite\Annotations\Field; +use App\Entities\Product; + +/** + * @ExtendType(class=Product::class) + */ +class ProductType +{ + private $translationService; + + public function __construct(TranslationServiceInterface $translationService) + { + $this->translationService = $translationService; + } + + /** + * @Field() + */ + public function getName(Product $product, string $language): string + { + return $this->translationService->getProductName($product->getId(), $language); + } +} +``` + + + + +Let's break this sample: + + + + +```php +#[ExtendType(class: Product::class)] +``` + + + + +```php +/** + * @ExtendType(class=Product::class) + */ +``` + + + + +With the `@ExtendType` annotation, we tell GraphQLite that we want to add fields in the GraphQL type mapped to +the `Product` PHP class. + +```php +class ProductType +{ + private $translationService; + + public function __construct(TranslationServiceInterface $translationService) + { + $this->translationService = $translationService; + } + + // ... +} +``` + + +- The `ProductType` class must be in the types namespace. You configured this namespace when you installed GraphQLite. +- The `ProductType` class is actually a **service**. You can therefore inject dependencies in it (like the `$translationService` in this example) + +
Heads up! The ProductType class must exist in the container of your +application and the container identifier MUST be the fully qualified class name.

+If you are using the Symfony bundle (or a framework with autowiring like Laravel), this +is usually not an issue as the container will automatically create the controller entry if you do not explicitly +declare it.
+ + + + +```php +#[Field] +public function getName(Product $product, string $language): string +{ + return $this->translationService->getProductName($product->getId(), $language); +} +``` + + + + +```php +/** + * @Field() + */ +public function getName(Product $product, string $language): string +{ + return $this->translationService->getProductName($product->getId(), $language); +} +``` + + + + +The `@Field` annotation is used to add the "name" field to the `Product` type. + +Take a close look at the signature. The first parameter is the "resolved object" we are working on. +Any additional parameters are used as arguments. + +Using the "[Type language](https://graphql.org/learn/schema/#type-language)" notation, we defined a type extension for +the GraphQL "Product" type: + +```graphql +Extend type Product { + name(language: !String): String! +} +``` + +
Type extension is a very powerful tool. Use it to add fields that needs to be +computed from services not available in the entity. +
diff --git a/website/versioned_docs/version-7.0.0/external-type-declaration.mdx b/website/versioned_docs/version-7.0.0/external-type-declaration.mdx new file mode 100644 index 0000000000..d11cd9ad77 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/external-type-declaration.mdx @@ -0,0 +1,295 @@ +--- +id: external-type-declaration +title: External type declaration +sidebar_label: External type declaration +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +In some cases, you cannot or do not want to put an annotation on a domain class. + +For instance: + +* The class you want to annotate is part of a third party library and you cannot modify it +* You are doing domain-driven design and don't want to clutter your domain object with annotations from the view layer +* etc. + +## `@Type` annotation with the `class` attribute + +GraphQLite allows you to use a *proxy* class thanks to the `@Type` annotation with the `class` attribute: + + + + +```php +namespace App\Types; + +use TheCodingMachine\GraphQLite\Annotations\Type; +use TheCodingMachine\GraphQLite\Annotations\Field; +use App\Entities\Product; + +#[Type(class: Product::class)] +class ProductType +{ + #[Field] + public function getId(Product $product): string + { + return $product->getId(); + } +} +``` + + + + +```php +namespace App\Types; + +use TheCodingMachine\GraphQLite\Annotations\Type; +use TheCodingMachine\GraphQLite\Annotations\Field; +use App\Entities\Product; + +/** + * @Type(class=Product::class) + */ +class ProductType +{ + /** + * @Field() + */ + public function getId(Product $product): string + { + return $product->getId(); + } +} +``` + + + + +The `ProductType` class must be in the *types* namespace. You configured this namespace when you installed GraphQLite. + +The `ProductType` class is actually a **service**. You can therefore inject dependencies in it. + +
Heads up! The ProductType class must exist in the container of your application and the container identifier MUST be the fully qualified class name.

+If you are using the Symfony bundle (or a framework with autowiring like Laravel), this +is usually not an issue as the container will automatically create the controller entry if you do not explicitly +declare it.
+ +In methods with a `@Field` annotation, the first parameter is the *resolved object* we are working on. Any additional parameters are used as arguments. + +## `@SourceField` annotation + +If you don't want to rewrite all *getters* of your base class, you may use the `@SourceField` annotation: + + + + +```php +use TheCodingMachine\GraphQLite\Annotations\Type; +use TheCodingMachine\GraphQLite\Annotations\SourceField; +use App\Entities\Product; + +#[Type(class: Product::class)] +#[SourceField(name: "name")] +#[SourceField(name: "price")] +class ProductType +{ +} +``` + + + + +```php +use TheCodingMachine\GraphQLite\Annotations\Type; +use TheCodingMachine\GraphQLite\Annotations\SourceField; +use App\Entities\Product; + +/** + * @Type(class=Product::class) + * @SourceField(name="name") + * @SourceField(name="price") + */ +class ProductType +{ +} +``` + + + + +By doing so, you let GraphQLite know that the type exposes the `getName` method of the underlying `Product` object. + +Internally, GraphQLite will look for methods named `name()`, `getName()` and `isName()`). +You can set different name to look for with `sourceName` attribute. + +## `@MagicField` annotation + +If your object has no getters, but instead uses magic properties (using the magic `__get` method), you should use the `@MagicField` annotation: + + + + +```php +use TheCodingMachine\GraphQLite\Annotations\Type; +use TheCodingMachine\GraphQLite\Annotations\SourceField; +use App\Entities\Product; + +#[Type] +#[MagicField(name: "name", outputType: "String!")] +#[MagicField(name: "price", outputType: "Float")] +class ProductType +{ + public function __get(string $property) { + // return some magic property + } +} +``` + + + + +```php +use TheCodingMachine\GraphQLite\Annotations\Type; +use TheCodingMachine\GraphQLite\Annotations\SourceField; +use App\Entities\Product; + +/** + * @Type() + * @MagicField(name="name", outputType="String!") + * @MagicField(name="price", outputType="Float") + */ +class ProductType +{ + public function __get(string $property) { + // return some magic property + } +} +``` + + + + +By doing so, you let GraphQLite know that the type exposes "name" and the "price" magic properties of the underlying `Product` object. +You can set different name to look for with `sourceName` attribute. + +This is particularly useful in frameworks like Laravel, where Eloquent is making a very wide use of such properties. + +Please note that GraphQLite has no way to know the type of a magic property. Therefore, you have specify the GraphQL type +of each property manually. + +### Authentication and authorization + +You may also check for logged users or users with a specific right using the "annotations" property. + +```php +use TheCodingMachine\GraphQLite\Annotations\Type; +use TheCodingMachine\GraphQLite\Annotations\SourceField; +use TheCodingMachine\GraphQLite\Annotations\Logged; +use TheCodingMachine\GraphQLite\Annotations\Right; +use TheCodingMachine\GraphQLite\Annotations\FailWith; +use App\Entities\Product; + +/** + * @Type(class=Product::class) + * @SourceField(name="name") + * @SourceField(name="price", annotations={@Logged, @Right(name="CAN_ACCESS_Price", @FailWith(null)})) + */ +class ProductType extends AbstractAnnotatedObjectType +{ +} +``` + +Any annotations described in the [Authentication and authorization page](authentication-authorization.mdx), or any annotation this is actually a ["field middleware"](field-middlewares.md) can be used in the `@SourceField` "annotations" attribute. + +
Heads up! The "annotation" attribute in @SourceField and @MagicField is only available as a Doctrine annotations. You cannot use it in PHP 8 attributes (because PHP 8 attributes cannot be nested)
+ +## Declaring fields dynamically (without annotations) + +In some very particular cases, you might not know exactly the list of `@SourceField` annotations at development time. +If you need to decide the list of `@SourceField` at runtime, you can implement the `FromSourceFieldsInterface`: + + + + +```php +use TheCodingMachine\GraphQLite\FromSourceFieldsInterface; + +#[Type(class: Product::class)] +class ProductType implements FromSourceFieldsInterface +{ + /** + * Dynamically returns the array of source fields + * to be fetched from the original object. + * + * @return SourceFieldInterface[] + */ + public function getSourceFields(): array + { + // You may want to enable fields conditionally based on feature flags... + if (ENABLE_STATUS_GLOBALLY) { + return [ + new SourceField(['name'=>'status', 'logged'=>true]), + ]; + } else { + return []; + } + } +} +``` + + + + +```php +use TheCodingMachine\GraphQLite\FromSourceFieldsInterface; + +/** + * @Type(class=Product::class) + */ +class ProductType implements FromSourceFieldsInterface +{ + /** + * Dynamically returns the array of source fields + * to be fetched from the original object. + * + * @return SourceFieldInterface[] + */ + public function getSourceFields(): array + { + // You may want to enable fields conditionally based on feature flags... + if (ENABLE_STATUS_GLOBALLY) { + return [ + new SourceField(['name'=>'status', 'logged'=>true]), + ]; + } else { + return []; + } + } +} +``` + + + diff --git a/website/versioned_docs/version-7.0.0/field-middlewares.md b/website/versioned_docs/version-7.0.0/field-middlewares.md new file mode 100644 index 0000000000..18eac15b30 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/field-middlewares.md @@ -0,0 +1,139 @@ +--- +id: field-middlewares +title: Adding custom annotations with Field middlewares +sidebar_label: Custom annotations +--- + +Available in GraphQLite 4.0+ + +Just like the `@Logged` or `@Right` annotation, you can develop your own annotation that extends/modifies the behaviour of a field/query/mutation. + +
+ If you want to create an annotation that targets a single argument (like @AutoWire(for="$service")), you should rather check the documentation about custom argument resolving +
+ +## Field middlewares + +GraphQLite is based on the Webonyx/Graphql-PHP library. In Webonyx, fields are represented by the `FieldDefinition` class. +In order to create a `FieldDefinition` instance for your field, GraphQLite goes through a series of "middlewares". + +![](/img/field_middleware.svg) + +Each middleware is passed a `TheCodingMachine\GraphQLite\QueryFieldDescriptor` instance. This object contains all the +parameters used to initialize the field (like the return type, the list of arguments, the resolver to be used, etc...) + +Each middleware must return a `GraphQL\Type\Definition\FieldDefinition` (the object representing a field in Webonyx/GraphQL-PHP). + +```php +/** + * Your middleware must implement this interface. + */ +interface FieldMiddlewareInterface +{ + public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandlerInterface $fieldHandler): ?FieldDefinition; +} +``` + +```php +class QueryFieldDescriptor +{ + public function getName() { /* ... */ } + public function withName(string $name): self { /* ... */ } + public function getType() { /* ... */ } + public function withType($type): self { /* ... */ } + public function getParameters(): array { /* ... */ } + public function withParameters(array $parameters): self { /* ... */ } + public function withCallable(callable $callable): self { /* ... */ } + public function withTargetMethodOnSource(?string $targetMethodOnSource): self { /* ... */ } + public function isInjectSource(): bool { /* ... */ } + public function withInjectSource(bool $injectSource): self { /* ... */ } + public function getComment(): ?string { /* ... */ } + public function withComment(?string $comment): self { /* ... */ } + public function getMiddlewareAnnotations(): MiddlewareAnnotations { /* ... */ } + public function withMiddlewareAnnotations(MiddlewareAnnotations $middlewareAnnotations): self { /* ... */ } + public function getOriginalResolver(): ResolverInterface { /* ... */ } + public function getResolver(): callable { /* ... */ } + public function withResolver(callable $resolver): self { /* ... */ } +} +``` + +The role of a middleware is to analyze the `QueryFieldDescriptor` and modify it (or to directly return a `FieldDefinition`). + +If you want the field to purely disappear, your middleware can return `null`, although this should be used with caution: +field middlewares only get called once per Schema instance. If you use a long-running server (like Laravel Octane, Swoole, RoadRunner etc) +and share the same Schema instance across requests, you will not be able to hide fields based on request data. + +## Annotations parsing + +Take a look at the `QueryFieldDescriptor::getMiddlewareAnnotations()`. + +It returns the list of annotations applied to your field that implements the `MiddlewareAnnotationInterface`. + +Let's imagine you want to add a `@OnlyDebug` annotation that displays a field/query/mutation only in debug mode (and +hides the field in production). That could be useful, right? + +First, we have to define the annotation. Annotations are handled by the great [doctrine/annotations](https://www.doctrine-project.org/projects/doctrine-annotations/en/1.6/index.html) library (for PHP 7+) and/or by PHP 8 attributes. + +```php title="OnlyDebug.php" +namespace App\Annotations; + +use Attribute; +use TheCodingMachine\GraphQLite\Annotations\MiddlewareAnnotationInterface; + +/** + * @Annotation + * @Target({"METHOD", "ANNOTATION"}) + */ +#[Attribute(Attribute::TARGET_METHOD)] +class OnlyDebug implements MiddlewareAnnotationInterface +{ +} +``` + +Apart from being a classical annotation/attribute, this class implements the `MiddlewareAnnotationInterface`. This interface is a "marker" interface. It does not have any methods. It is just used to tell GraphQLite that this annotation is to be used by middlewares. + +Now, we can write a middleware that will act upon this annotation. + +```php +namespace App\Middlewares; + +use App\Annotations\OnlyDebug; +use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewareInterface; +use GraphQL\Type\Definition\FieldDefinition; +use TheCodingMachine\GraphQLite\QueryFieldDescriptor; + +/** + * Middleware in charge of hiding a field if it is annotated with @OnlyDebug and the DEBUG constant is not set + */ +class OnlyDebugFieldMiddleware implements FieldMiddlewareInterface +{ + public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandlerInterface $fieldHandler): ?FieldDefinition + { + $annotations = $queryFieldDescriptor->getMiddlewareAnnotations(); + + /** + * @var OnlyDebug $onlyDebug + */ + $onlyDebug = $annotations->getAnnotationByType(OnlyDebug::class); + + if ($onlyDebug !== null && !DEBUG) { + // If the onlyDebug annotation is present, returns null. + // Returning null will hide the field. + return null; + } + + // Otherwise, let's continue the middleware pipe without touching anything. + return $fieldHandler->handle($queryFieldDescriptor); + } +} +``` + +The final thing we have to do is to register the middleware. + +- Assuming you are using the `SchemaFactory` to initialize GraphQLite, you can register the field middleware using: + + ```php + $schemaFactory->addFieldMiddleware(new OnlyDebugFieldMiddleware()); + ``` + +- If you are using the Symfony bundle, you can register your field middleware services by tagging them with the `graphql.field_middleware` tag. diff --git a/website/versioned_docs/version-7.0.0/file-uploads.mdx b/website/versioned_docs/version-7.0.0/file-uploads.mdx new file mode 100644 index 0000000000..8c0782f89b --- /dev/null +++ b/website/versioned_docs/version-7.0.0/file-uploads.mdx @@ -0,0 +1,91 @@ +--- +id: file-uploads +title: File uploads +sidebar_label: File uploads +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +GraphQL does not support natively the notion of file uploads, but an extension to the GraphQL protocol was proposed +to add support for [multipart requests](https://github.com/jaydenseric/graphql-multipart-request-spec). + +## Installation + +GraphQLite supports this extension through the use of the [Ecodev/graphql-upload](https://github.com/Ecodev/graphql-upload) library. + +You must start by installing this package: + +```console +$ composer require ecodev/graphql-upload +``` + +### If you are using the Symfony bundle + +If you are using our Symfony bundle, the file upload middleware is managed by the bundle. You have nothing to do +and can start using it right away. + +### If you are using a PSR-15 compatible framework + +In order to use this, you must first be sure that the `ecodev/graphql-upload` PSR-15 middleware is part of your middleware pipe. + +Simply add `GraphQL\Upload\UploadMiddleware` to your middleware pipe. + +### If you are using another framework not compatible with PSR-15 + +Please check the Ecodev/graphql-upload library [documentation](https://github.com/Ecodev/graphql-upload) +for more information on how to integrate it in your framework. + +## Usage + +To handle an uploaded file, you type-hint against the PSR-7 `UploadedFileInterface`: + + + + +```php +class MyController +{ + #[Mutation] + public function saveDocument(string $name, UploadedFileInterface $file): Document + { + // Some code that saves the document. + $file->moveTo($someDir); + } +} +``` + + + + +```php +class MyController +{ + /** + * @Mutation + */ + public function saveDocument(string $name, UploadedFileInterface $file): Document + { + // Some code that saves the document. + $file->moveTo($someDir); + } +} +``` + + + + +Of course, you need to use a GraphQL client that is compatible with multipart requests. See [jaydenseric/graphql-multipart-request-spec](https://github.com/jaydenseric/graphql-multipart-request-spec#client) for a list of compatible clients. + +The GraphQL client must send the file using the Upload type. + +```graphql +mutation upload($file: Upload!) { + upload(file: $file) +} +``` diff --git a/website/versioned_docs/version-7.0.0/fine-grained-security.mdx b/website/versioned_docs/version-7.0.0/fine-grained-security.mdx new file mode 100644 index 0000000000..f5d2fff032 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/fine-grained-security.mdx @@ -0,0 +1,421 @@ +--- +id: fine-grained-security +title: Fine grained security +sidebar_label: Fine grained security +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +If the [`@Logged` and `@Right` annotations](authentication-authorization.mdx#logged-and-right-annotations) are not +granular enough for your needs, you can use the advanced `@Security` annotation. + +Using the `@Security` annotation, you can write an *expression* that can contain custom logic. For instance: + +- Check that a user can access a given resource +- Check that a user has one right or another right +- ... + +## Using the @Security annotation + +The `@Security` annotation is very flexible: it allows you to pass an expression that can contains custom logic: + + + + +```php +use TheCodingMachine\GraphQLite\Annotations\Security; + +// ... + +#[Query] +#[Security("is_granted('ROLE_ADMIN') or is_granted('POST_SHOW', post)")] +public function getPost(Post $post): array +{ + // ... +} +``` + + + + +```php +use TheCodingMachine\GraphQLite\Annotations\Security; + +// ... + +/** + * @Query + * @Security("is_granted('ROLE_ADMIN') or is_granted('POST_SHOW', post)") + */ +public function getPost(Post $post): array +{ + // ... +} +``` + + + + +The *expression* defined in the `@Security` annotation must conform to [Symfony's Expression Language syntax](https://symfony.com/doc/4.4/components/expression_language/syntax.html) + +
+ If you are a Symfony user, you might already be used to the @Security annotation. Most of the inspiration + of this annotation comes from Symfony. Warning though! GraphQLite's @Security annotation and + Symfony's @Security annotation are slightly different. Especially, the two annotations do not live + in the same namespace! +
+ +## The `is_granted` function + +Use the `is_granted` function to check if a user has a special right. + + + + +```php +#[Security("is_granted('ROLE_ADMIN')")] +``` + + + + +```php +@Security("is_granted('ROLE_ADMIN')") +``` + + + + +is similar to + + + + +```php +#[Right("ROLE_ADMIN")] +``` + + + + +```php +@Right("ROLE_ADMIN") +``` + + + + +In addition, the `is_granted` function accepts a second optional parameter: the "scope" of the right. + + + + +```php +#[Query] +#[Security("is_granted('POST_SHOW', post)")] +public function getPost(Post $post): array +{ + // ... +} +``` + + + + +```php +/** + * @Query + * @Security("is_granted('POST_SHOW', post)") + */ +public function getPost(Post $post): array +{ + // ... +} +``` + + + + +In the example above, the `getPost` method can be called only if the logged user has the 'POST_SHOW' permission on the +`$post` object. You can notice that the `$post` object comes from the parameters. + +## Accessing method parameters + +All parameters passed to the method can be accessed in the `@Security` expression. + + + + +```php +#[Query] +#[Security(expression: "startDate < endDate", statusCode: 400, message: "End date must be after start date")] +public function getPosts(DateTimeImmutable $startDate, DateTimeImmutable $endDate): array +{ + // ... +} +``` + + + + +```php +/** + * @Query + * @Security("startDate < endDate", statusCode=400, message="End date must be after start date") + */ +public function getPosts(DateTimeImmutable $startDate, DateTimeImmutable $endDate): array +{ + // ... +} +``` + + + + + +In the example above, we tweak a bit the Security annotation purpose to do simple input validation. + +## Setting HTTP code and error message + +You can use the `statusCode` and `message` attributes to set the HTTP code and GraphQL error message. + + + + +```php +#[Query] +#[Security(expression: "is_granted('POST_SHOW', post)", statusCode: 404, message: "Post not found (let's pretend the post does not exists!)")] +public function getPost(Post $post): array +{ + // ... +} +``` + + + + +```php +/** + * @Query + * @Security("is_granted('POST_SHOW', post)", statusCode=404, message="Post not found (let's pretend the post does not exists!)") + */ +public function getPost(Post $post): array +{ + // ... +} +``` + + + + +Note: since a single GraphQL call contain many errors, 2 errors might have conflicting HTTP status code. +The resulting status code is up to the GraphQL middleware you use. Most of the time, the status code with the +higher error code will be returned. + +## Setting a default value + +If you do not want an error to be thrown when the security condition is not met, you can use the `failWith` attribute +to set a default value. + + + + +```php +#[Query] +#[Security(expression: "is_granted('CAN_SEE_MARGIN', this)", failWith: null)] +public function getMargin(): float +{ + // ... +} +``` + + + + +```php +/** + * @Field + * @Security("is_granted('CAN_SEE_MARGIN', this)", failWith=null) + */ +public function getMargin(): float +{ + // ... +} +``` + + + + +The `failWith` attribute behaves just like the [`@FailWith` annotation](authentication-authorization.mdx#not-throwing-errors) +but for a given `@Security` annotation. + +You cannot use the `failWith` attribute along `statusCode` or `message` attributes. + +## Accessing the user + +You can use the `user` variable to access the currently logged user. +You can use the `is_logged()` function to check if a user is logged or not. + + + + +```php +#[Query] +#[Security("is_logged() && user.age > 18")] +public function getNSFWImages(): array +{ + // ... +} +``` + + + + +```php +/** + * @Query + * @Security("is_logged() && user.age > 18") + */ +public function getNSFWImages(): array +{ + // ... +} +``` + + + + +## Accessing the current object + +You can use the `this` variable to access any (public) property / method of the current class. + + + + +```php +class Post { + #[Field] + #[Security("this.canAccessBody(user)")] + public function getBody(): array + { + // ... + } + + public function canAccessBody(User $user): bool + { + // Some custom logic here + } +} +``` + + + + +```php +class Post { + /** + * @Field + * @Security("this.canAccessBody(user)") + */ + public function getBody(): array + { + // ... + } + + public function canAccessBody(User $user): bool + { + // Some custom logic here + } +} +``` + + + + +## Available scope + +The `@Security` annotation can be used in any query, mutation or field, so anywhere you have a `@Query`, `@Mutation` +or `@Field` annotation. + +## How to restrict access to a given resource + +The `is_granted` method can be used to restrict access to a specific resource. + + + + +```php +#[Security("is_granted('POST_SHOW', post)")] +``` + + + + +```php +@Security("is_granted('POST_SHOW', post)") +``` + + + + +If you are wondering how to configure these fine-grained permissions, this is not something that GraphQLite handles +itself. Instead, this depends on the framework you are using. + +If you are using Symfony, you will [create a custom voter](https://symfony.com/doc/current/security/voters.html). + +If you are using Laravel, you will [create a Gate or a Policy](https://laravel.com/docs/6.x/authorization). + +If you are using another framework, you need to know that the `is_granted` function simply forwards the call to +the `isAllowed` method of the configured `AuthorizationSerice`. See [Connecting GraphQLite to your framework's security module +](implementing-security.md) for more details diff --git a/website/versioned_docs/version-7.0.0/getting-started.md b/website/versioned_docs/version-7.0.0/getting-started.md new file mode 100644 index 0000000000..4eadcfa3fe --- /dev/null +++ b/website/versioned_docs/version-7.0.0/getting-started.md @@ -0,0 +1,16 @@ +--- +id: getting-started +title: Getting started +sidebar_label: Getting Started +--- + +GraphQLite is a framework agnostic library. You can use it in any PHP project as long as you know how to +inject services in your favorite framework's container. + +Currently, we provide bundle/packages to help you get started with Symfony, Laravel and any framework compatible +with container-interop/service-provider. + +- [Get started with Symfony](symfony-bundle.md) +- [Get started with Laravel](laravel-package.md) +- [Get started with a framework compatible with container-interop/service-provider](universal-service-providers.md) +- [Get started with another framework (or no framework)](other-frameworks.mdx) diff --git a/website/versioned_docs/version-7.0.0/implementing-security.md b/website/versioned_docs/version-7.0.0/implementing-security.md new file mode 100644 index 0000000000..ce3c8fb43f --- /dev/null +++ b/website/versioned_docs/version-7.0.0/implementing-security.md @@ -0,0 +1,57 @@ +--- +id: implementing-security +title: Connecting GraphQLite to your framework's security module +sidebar_label: Connecting security to your framework +--- + +
+ At the time of writing, the Symfony Bundle and the Laravel package handle this implementation. For the latest documentation, please see their respective Github repositories. +
+ +GraphQLite needs to know if a user is logged or not, and what rights it has. +But this is specific of the framework you use. + +To plug GraphQLite to your framework's security mechanism, you will have to provide two classes implementing: + +* `TheCodingMachine\GraphQLite\Security\AuthenticationServiceInterface` +* `TheCodingMachine\GraphQLite\Security\AuthorizationServiceInterface` + +Those two interfaces act as adapters between GraphQLite and your framework: + +```php +interface AuthenticationServiceInterface +{ + /** + * Returns true if the "current" user is logged + */ + public function isLogged(): bool; + + /** + * Returns an object representing the current logged user. + * Can return null if the user is not logged. + */ + public function getUser(): ?object; +} +``` + +```php +interface AuthorizationServiceInterface +{ + /** + * Returns true if the "current" user has access to the right "$right" + * + * @param mixed $subject The scope this right applies on. $subject is typically an object or a FQCN. Set $subject to "null" if the right is global. + */ + public function isAllowed(string $right, $subject = null): bool; +} +``` + +You need to write classes that implement these interfaces. Then, you must register those classes with GraphQLite. +It you are [using the `SchemaFactory`](other-frameworks.mdx), you can register your classes using: + +```php +// Configure an authentication service (to resolve the @Logged annotations). +$schemaFactory->setAuthenticationService($myAuthenticationService); +// Configure an authorization service (to resolve the @Right annotations). +$schemaFactory->setAuthorizationService($myAuthorizationService); +``` diff --git a/website/versioned_docs/version-7.0.0/inheritance-interfaces.mdx b/website/versioned_docs/version-7.0.0/inheritance-interfaces.mdx new file mode 100644 index 0000000000..05f0404a0e --- /dev/null +++ b/website/versioned_docs/version-7.0.0/inheritance-interfaces.mdx @@ -0,0 +1,312 @@ +--- +id: inheritance-interfaces +title: Inheritance and interfaces +sidebar_label: Inheritance and interfaces +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Modeling inheritance + +Some of your entities may extend other entities. GraphQLite will do its best to represent this hierarchy of objects in GraphQL using interfaces. + +Let's say you have two classes, `Contact` and `User` (which extends `Contact`): + + + + +```php +#[Type] +class Contact +{ + // ... +} + +#[Type] +class User extends Contact +{ + // ... +} +``` + + + + +```php +/** + * @Type + */ +class Contact +{ + // ... +} + +/** + * @Type + */ +class User extends Contact +{ + // ... +} +``` + + + + +Now, let's assume you have a query that returns a contact: + + + + +```php +class ContactController +{ + #[Query] + public function getContact(): Contact + { + // ... + } +} +``` + + + + +```php +class ContactController +{ + /** + * @Query() + */ + public function getContact(): Contact + { + // ... + } +} +``` + + + + +When writing your GraphQL query, you are able to use fragments to retrieve fields from the `User` type: + +```graphql +contact { + name + ... User { + email + } +} +``` + +Written in [GraphQL type language](https://graphql.org/learn/schema/#type-language), the representation of types +would look like this: + +```graphql +interface ContactInterface { + // List of fields declared in Contact class +} + +type Contact implements ContactInterface { + // List of fields declared in Contact class +} + +type User implements ContactInterface { + // List of fields declared in Contact and User classes +} +``` + +Behind the scene, GraphQLite will detect that the `Contact` class is extended by the `User` class. +Because the class is extended, a GraphQL `ContactInterface` interface is created dynamically. + +The GraphQL `User` type will also automatically implement this `ContactInterface`. The interface contains all the fields +available in the `Contact` type. + +## Mapping interfaces + +If you want to create a pure GraphQL interface, you can also add a `@Type` annotation on a PHP interface. + + + + +```php +#[Type] +interface UserInterface +{ + #[Field] + public function getUserName(): string; +} +``` + + + + +```php +/** + * @Type + */ +interface UserInterface +{ + /** + * @Field + */ + public function getUserName(): string; +} +``` + + + + +This will automatically create a GraphQL interface whose description is: + +```graphql +interface UserInterface { + userName: String! +} +``` + +### Implementing interfaces + +You don't have to do anything special to implement an interface in your GraphQL types. +Simply "implement" the interface in PHP and you are done! + + + + +```php +#[Type] +class User implements UserInterface +{ + public function getUserName(): string; +} +``` + + + + +```php +/** + * @Type + */ +class User implements UserInterface +{ + public function getUserName(): string; +} +``` + + + + +This will translate in GraphQL schema as: + +```graphql +interface UserInterface { + userName: String! +} + +type User implements UserInterface { + userName: String! +} +``` + +Please note that you do not need to put the `@Field` annotation again in the implementing class. + +### Interfaces without an explicit implementing type + +You don't have to explicitly put a `@Type` annotation on the class implementing the interface (though this +is usually a good idea). + + + + +```php +/** + * Look, this class has no #Type attribute + */ +class User implements UserInterface +{ + public function getUserName(): string; +} +``` + +```php +class UserController +{ + #[Query] + public function getUser(): UserInterface // This will work! + { + // ... + } +} +``` + + + + +```php +/** + * Look, this class has no @Type annotation + */ +class User implements UserInterface +{ + public function getUserName(): string; +} +``` + +```php +class UserController +{ + /** + * @Query() + */ + public function getUser(): UserInterface // This will work! + { + // ... + } +} +``` + + + + +
If GraphQLite cannot find a proper GraphQL Object type implementing an interface, it +will create an object type "on the fly".
+ +In the example above, because the `User` class has no `@Type` annotations, GraphQLite will +create a `UserImpl` type that implements `UserInterface`. + +```graphql +interface UserInterface { + userName: String! +} + +type UserImpl implements UserInterface { + userName: String! +} +``` diff --git a/website/versioned_docs/version-7.0.0/input-types.mdx b/website/versioned_docs/version-7.0.0/input-types.mdx new file mode 100644 index 0000000000..f2c62afd40 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/input-types.mdx @@ -0,0 +1,696 @@ +--- +id: input-types +title: Input types +sidebar_label: Input types +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +Let's assume you are developing an API that returns a list of cities around a location. + +Your GraphQL query might look like this: + + + + +```php +class MyController +{ + /** + * @return City[] + */ + #[Query] + public function getCities(Location $location, float $radius): array + { + // Some code that returns an array of cities. + } +} + +// Class Location is a simple value-object. +class Location +{ + private $latitude; + private $longitude; + + public function __construct(float $latitude, float $longitude) + { + $this->latitude = $latitude; + $this->longitude = $longitude; + } + + public function getLatitude(): float + { + return $this->latitude; + } + + public function getLongitude(): float + { + return $this->longitude; + } +} +``` + + + + +```php +class MyController +{ + /** + * @Query + * @return City[] + */ + public function getCities(Location $location, float $radius): array + { + // Some code that returns an array of cities. + } +} + +// Class Location is a simple value-object. +class Location +{ + private $latitude; + private $longitude; + + public function __construct(float $latitude, float $longitude) + { + $this->latitude = $latitude; + $this->longitude = $longitude; + } + + public function getLatitude(): float + { + return $this->latitude; + } + + public function getLongitude(): float + { + return $this->longitude; + } +} +``` + + + + +If you try to run this code, you will get the following error: + +``` +CannotMapTypeException: cannot map class "Location" to a known GraphQL input type. Check your TypeMapper configuration. +``` + +You are running into this error because GraphQLite does not know how to handle the `Location` object. + +In GraphQL, an object passed in parameter of a query or mutation (or any field) is called an **Input Type**. + +There are two ways for declaring that type, in GraphQLite: using the [`#[Input]` attribute](input-attribute) or a [Factory method](factory). + +## #\[Input\] Attribute + +Using the `#[Input]` attribute, we can transform the `Location` class, in the example above, into an input type. Just add the `#[Field]` attribute to the corresponding properties: + + + + +```php +#[Input] +class Location +{ + + #[Field] + private ?string $name = null; + + #[Field] + private float $latitude; + + #[Field] + private float $longitude; + + public function __construct(float $latitude, float $longitude) + { + $this->latitude = $latitude; + $this->longitude = $longitude; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getLatitude(): float + { + return $this->latitude; + } + + public function getLongitude(): float + { + return $this->longitude; + } +} +``` + + + + +```php +/** + * @Input + */ +class Location +{ + + /** + * @Field + * @var string|null + */ + private ?string $name = null; + + /** + * @Field + * @var float + */ + private $latitude; + + /** + * @Field + * @var float + */ + private $longitude; + + public function __construct(float $latitude, float $longitude) + { + $this->latitude = $latitude; + $this->longitude = $longitude; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getLatitude(): float + { + return $this->latitude; + } + + public function getLongitude(): float + { + return $this->longitude; + } +} +``` + + + + +Now if you call the `getCities` query, from the controller in the first example, the `Location` object will be automatically instantiated with the user provided, `latitude` / `longitude` properties, and passed to the controller as a parameter. + +There are some important things to notice: + +- The `@Field` annotation is recognized on properties for Input Type, as well as setters. +- There are 3 ways for fields to be resolved: + - Via constructor if corresponding properties are mentioned as parameters with the same names - exactly as in the example above. + - If properties are public, they will be just set without any additional effort - no constructor required. + - For private or protected properties implemented, a public setter is required (if they are not set via the constructor). For example `setLatitude(float $latitude)`. You can also put the `@Field` annotation on the setter, instead of the property, allowing you to have use many other attributes (`Security`, `Right`, `Autowire`, etc.). +- For validation of these Input Types, see the [Custom InputType Validation section](validation#custom-inputtype-validation). +- It's advised to use the `#[Input]` attribute on DTO style input type objects and not directly on your model objects. Using it on your model objects can cause coupling in undesirable ways. + +### Multiple Input Types from the same class + +Simple usage of the `@Input` annotation on a class creates a GraphQL input named by class name + "Input" suffix if a class name does not end with it already. Ex. `LocationInput` for `Location` class. + +You can add multiple `@Input` annotations to the same class, give them different names and link different fields. +Consider the following example: + + + + +```php +#[Input(name: 'CreateUserInput', default: true)] +#[Input(name: 'UpdateUserInput', update: true)] +class UserInput +{ + + #[Field] + public string $username; + + #[Field(for: 'CreateUserInput')] + public string $email; + + #[Field(for: 'CreateUserInput', inputType: 'String!')] + #[Field(for: 'UpdateUserInput', inputType: 'String')] + public string $password; + + protected ?int $age; + + + #[Field] + public function setAge(?int $age): void + { + $this->age = $age; + } +} +``` + + + + +```php +/** + * @Input(name="CreateUserInput", default=true) + * @Input(name="UpdateUserInput", update=true) + */ +class UserInput +{ + + /** + * @Field() + * @var string + */ + public $username; + + /** + * @Field(for="CreateUserInput") + * @var string + */ + public string $email; + + /** + * @Field(for="CreateUserInput", inputType="String!") + * @Field(for="UpdateUserInput", inputType="String") + * @var string|null + */ + public $password; + + /** @var int|null */ + protected $age; + + /** + * @Field() + * @param int|null $age + */ + public function setAge(?int $age): void + { + $this->age = $age; + } +} +``` + + + + +There are 2 input types added to the `UserInput` class: `CreateUserInput` and `UpdateUserInput`. A few notes: +- `CreateUserInput` input will be used by default for this class. +- Field `username` is created for both input types, and it is required because the property type is not nullable. +- Field `email` will appear only for `CreateUserInput` input. +- Field `password` will appear for both. For `CreateUserInput` it'll be the required field and for `UpdateUserInput` optional. +- Field `age` is optional for both input types. + +Note that `update: true` argument for `UpdateUserInput`. It should be used when input type is used for a partial update, +It makes all fields optional and removes all default values from thus prevents setting default values via setters or directly to public properties. +In example above if you use the class as `UpdateUserInput` and set only `username` the other ones will be ignored. +In PHP 7 they will be set to `null`, while in PHP 8 they will be in not initialized state - this can be used as a trick +to check if user actually passed a value for a certain field. + +## Factory + +A **Factory** is a method that takes in parameter all the fields of the input type and return an object. + +Here is an example of factory: + + + + +```php +class MyFactory +{ + /** + * The Factory annotation will create automatically a LocationInput input type in GraphQL. + */ + #[Factory] + public function createLocation(float $latitude, float $longitude): Location + { + return new Location($latitude, $longitude); + } +} +``` + + + + +```php +class MyFactory +{ + /** + * The Factory annotation will create automatically a LocationInput input type in GraphQL. + * + * @Factory() + */ + public function createLocation(float $latitude, float $longitude): Location + { + return new Location($latitude, $longitude); + } +} +``` + + + + +and now, you can run query like this: + +```graphql +query { + getCities(location: { + latitude: 45.0, + longitude: 0.0, + }, + radius: 42) + { + id, + name + } +} +``` + +- Factories must be declared with the **@Factory** annotation. +- The parameters of the factories are the field of the GraphQL input type + +A few important things to notice: + +- The container MUST contain the factory class. The identifier of the factory MUST be the fully qualified class name of the class that contains the factory. + This is usually already the case if you are using a container with auto-wiring capabilities +- We recommend that you put the factories in the same directories as the types. + +### Specifying the input type name + +The GraphQL input type name is derived from the return type of the factory. + +Given the factory below, the return type is "Location", therefore, the GraphQL input type will be named "LocationInput". + + + + +```php +#[Factory] +public function createLocation(float $latitude, float $longitude): Location +{ + return new Location($latitude, $longitude); +} +``` + + + + +```php +/** + * @Factory() + */ +public function createLocation(float $latitude, float $longitude): Location +{ + return new Location($latitude, $longitude); +} +``` + + + + +In case you want to override the input type name, you can use the "name" attribute of the @Factory annotation: + + + + +```php +#[Factory(name: 'MyNewInputName', default: true)] +``` + + + + +```php +/** + * @Factory(name="MyNewInputName", default=true) + */ +``` + + + + +Note that you need to add the "default" attribute is you want your factory to be used by default (more on this in +the next chapter). + +Unless you want to have several factories for the same PHP class, the input type name will be completely transparent +to you, so there is no real reason to customize it. + +### Forcing an input type + +You can use the `@UseInputType` annotation to force an input type of a parameter. + +Let's say you want to force a parameter to be of type "ID", you can use this: + + + + +```php +#[Factory] +#[UseInputType(for: "$id", inputType:"ID!")] +public function getProductById(string $id): Product +{ + return $this->productRepository->findById($id); +} +``` + + + + +```php +/** + * @Factory() + * @UseInputType(for="$id", inputType="ID!") + */ +public function getProductById(string $id): Product +{ + return $this->productRepository->findById($id); +} +``` + + + + +### Declaring several input types for the same PHP class +Available in GraphQLite 4.0+ + +There are situations where a given PHP class might use one factory or another depending on the context. + +This is often the case when your objects map database entities. +In these cases, you can use combine the use of `@UseInputType` and `@Factory` annotation to achieve your goal. + +Here is an annotated sample: + + + + +```php +/** + * This class contains 2 factories to create Product objects. + * The "getProduct" method is used by default to map "Product" classes. + * The "createProduct" method will generate another input type named "CreateProductInput" + */ +class ProductFactory +{ + // ... + + /** + * This factory will be used by default to map "Product" classes. + */ + #[Factory(name: "ProductRefInput", default: true)] + public function getProduct(string $id): Product + { + return $this->productRepository->findById($id); + } + /** + * We specify a name for this input type explicitly. + */ + #[Factory(name: "CreateProductInput", default: false)] + public function createProduct(string $name, string $type): Product + { + return new Product($name, $type); + } +} + +class ProductController +{ + /** + * The "createProduct" factory will be used for this mutation. + */ + #[Mutation] + #[UseInputType(for: "$product", inputType: "CreateProductInput!")] + public function saveProduct(Product $product): Product + { + // ... + } + + /** + * The default "getProduct" factory will be used for this query. + * + * @return Color[] + */ + #[Query] + public function availableColors(Product $product): array + { + // ... + } +} +``` + + + + +```php +/** + * This class contains 2 factories to create Product objects. + * The "getProduct" method is used by default to map "Product" classes. + * The "createProduct" method will generate another input type named "CreateProductInput" + */ +class ProductFactory +{ + // ... + + /** + * This factory will be used by default to map "Product" classes. + * @Factory(name="ProductRefInput", default=true) + */ + public function getProduct(string $id): Product + { + return $this->productRepository->findById($id); + } + /** + * We specify a name for this input type explicitly. + * @Factory(name="CreateProductInput", default=false) + */ + public function createProduct(string $name, string $type): Product + { + return new Product($name, $type); + } +} + +class ProductController +{ + /** + * The "createProduct" factory will be used for this mutation. + * + * @Mutation + * @UseInputType(for="$product", inputType="CreateProductInput!") + */ + public function saveProduct(Product $product): Product + { + // ... + } + + /** + * The default "getProduct" factory will be used for this query. + * + * @Query + * @return Color[] + */ + public function availableColors(Product $product): array + { + // ... + } +} +``` + + + + +### Ignoring some parameters +Available in GraphQLite 4.0+ + +GraphQLite will automatically map all your parameters to an input type. +But sometimes, you might want to avoid exposing some of those parameters. + +Image your `getProductById` has an additional `lazyLoad` parameter. This parameter is interesting when you call +directly the function in PHP because you can have some level of optimisation on your code. But it is not something that +you want to expose in the GraphQL API. Let's hide it! + + + + +```php +#[Factory] +public function getProductById( + string $id, + #[HideParameter] + bool $lazyLoad = true + ): Product +{ + return $this->productRepository->findById($id, $lazyLoad); +} +``` + + + + +```php +/** + * @Factory() + * @HideParameter(for="$lazyLoad") + */ +public function getProductById(string $id, bool $lazyLoad = true): Product +{ + return $this->productRepository->findById($id, $lazyLoad); +} +``` + + + + +With the `@HideParameter` annotation, you can choose to remove from the GraphQL schema any argument. + +To be able to hide an argument, the argument must have a default value. diff --git a/website/versioned_docs/version-7.0.0/internals.md b/website/versioned_docs/version-7.0.0/internals.md new file mode 100644 index 0000000000..65624fcc49 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/internals.md @@ -0,0 +1,142 @@ +--- +id: internals +title: Internals +sidebar_label: Internals +--- + +## Mapping types + +The core of GraphQLite is its ability to map PHP types to GraphQL types. This mapping is performed by a series of +"type mappers". + +GraphQLite contains 4 categories of type mappers: + +- **Parameter mappers** +- **Root type mappers** +- **Recursive (class) type mappers** +- **(class) type mappers** + +```mermaid +graph TD; + classDef custom fill:#cfc,stroke:#7a7,stroke-width:2px,stroke-dasharray: 5, 5; + subgraph RootTypeMapperInterface + NullableTypeMapperAdapter-->CompoundTypeMapper + IteratorTypeMapper-->YourCustomRootTypeMapper + CompoundTypeMapper-->IteratorTypeMapper + YourCustomRootTypeMapper-->MyCLabsEnumTypeMapper + MyCLabsEnumTypeMapper-->EnumTypeMapper + EnumTypeMapper-->BaseTypeMapper + BaseTypeMapper-->FinalRootTypeMapper + end + subgraph RecursiveTypeMapperInterface + BaseTypeMapper-->RecursiveTypeMapper + end + subgraph TypeMapperInterface + RecursiveTypeMapper-->YourCustomTypeMapper + YourCustomTypeMapper-->PorpaginasTypeMapper + PorpaginasTypeMapper-->GlobTypeMapper + end + class YourCustomRootTypeMapper,YourCustomTypeMapper custom; +``` + +## Root type mappers + +(Classes implementing the [`RootTypeMapperInterface`](https://github.com/thecodingmachine/graphqlite/blob/master/src/Mappers/Root/RootTypeMapperInterface.php)) + +These type mappers are the first type mappers called. + +They are responsible for: + +- mapping scalar types (for instance mapping the "int" PHP type to GraphQL Integer type) +- detecting nullable/non-nullable types (for instance interpreting "?int" or "int|null") +- mapping list types (mapping a PHP array to a GraphQL list) +- mapping union types +- mapping enums + +Root type mappers have access to the *context* of a type: they can access the PHP DocBlock and read annotations. +If you want to write a custom type mapper that needs access to annotations, it needs to be a "root type mapper". + +GraphQLite provides 6 classes implementing `RootTypeMapperInterface`: + +- `NullableTypeMapperAdapter`: a type mapper in charge of making GraphQL types non-nullable if the PHP type is non-nullable +- `IteratorTypeMapper`: a type mapper in charge of iterable types (for instance: `MyIterator|User[]`) +- `CompoundTypeMapper`: a type mapper in charge of union types +- `MyCLabsEnumTypeMapper`: maps MyCLabs/enum types to GraphQL enum types (Deprecated: use native enums) +- `EnumTypeMapper`: maps PHP enums to GraphQL enum types +- `BaseTypeMapper`: maps scalar types and lists. Passes the control to the "recursive type mappers" if an object is encountered. +- `FinalRootTypeMapper`: the last type mapper of the chain, used to throw error if no other type mapper managed to handle the type. + +Type mappers are organized in a chain; each type-mapper is responsible for calling the next type mapper. + +```mermaid +graph TD; + classDef custom fill:#cfc,stroke:#7a7,stroke-width:2px,stroke-dasharray: 5, 5; + subgraph RootTypeMapperInterface + NullableTypeMapperAdapter-->CompoundTypeMapper + CompoundTypeMapper-->IteratorTypeMapper + IteratorTypeMapper-->YourCustomRootTypeMapper + YourCustomRootTypeMapper-->MyCLabsEnumTypeMapper + MyCLabsEnumTypeMapper-->EnumTypeMapper + EnumTypeMapper-->BaseTypeMapper + BaseTypeMapper-->FinalRootTypeMapper + end + class YourCustomRootTypeMapper custom; +``` + +## Class type mappers + +(Classes implementing the [`TypeMapperInterface`](https://github.com/thecodingmachine/graphqlite/blob/master/src/Mappers/TypeMapperInterface.php)) + +Class type mappers are mapping PHP classes to GraphQL object types. + +GraphQLite provide 3 default implementations: + +- `CompositeTypeMapper`: a type mapper that delegates mapping to other type mappers using the Composite Design Pattern. +- `GlobTypeMapper`: scans classes in a directory for the `@Type` or `@ExtendType` annotation and maps those to GraphQL types +- `PorpaginasTypeMapper`: maps and class implementing the Porpaginas `Result` interface to a [special paginated type](pagination.mdx). + +### Registering a type mapper in Symfony + +If you are using the GraphQLite Symfony bundle, you can register a type mapper by tagging the service with the "graphql.type_mapper" tag. + +### Registering a type mapper using the SchemaFactory + +If you are using the `SchemaFactory` to bootstrap GraphQLite, you can register a type mapper using the `SchemaFactory::addTypeMapper` method. + +## Recursive type mappers + +(Classes implementing the [`RecursiveTypeMapperInterface`](https://github.com/thecodingmachine/graphqlite/blob/master/src/Mappers/RecursiveTypeMapperInterface.php)) + +There is only one implementation of the `RecursiveTypeMapperInterface`: the `RecursiveTypeMapper`. + +Standard "class type mappers" are mapping a given PHP class to a GraphQL type. But they do not handle class hierarchies. +This is the role of the "recursive type mapper". + +Imagine that class "B" extends class "A" and class "A" maps to GraphQL type "AType". + +Since "B" *is a* "A", the "recursive type mapper" role is to make sure that "B" will also map to GraphQL type "AType". + +## Parameter mapper middlewares + +"Parameter middlewares" are used to decide what argument should be injected into a parameter. + +Let's have a look at a simple query: + +```php +/** + * @Query + * @return Product[] + */ +public function products(ResolveInfo $info): array +``` + +As you may know, [the `ResolveInfo` object injected in this query comes from Webonyx/GraphQL-PHP library](query-plan.mdx). +GraphQLite knows that is must inject a `ResolveInfo` instance because it comes with a [`ResolveInfoParameterHandler`](https://github.com/thecodingmachine/graphqlite/blob/master/src/Mappers/Parameters/ResolveInfoParameterHandler.php) class +that implements the [`ParameterMiddlewareInterface`](https://github.com/thecodingmachine/graphqlite/blob/master/src/Mappers/Parameters/ParameterMiddlewareInterface.php)). + +You can register your own parameter middlewares using the `SchemaFactory::addParameterMiddleware()` method, or by tagging the +service as "graphql.parameter_middleware" if you are using the Symfony bundle. + +
+ Use a parameter middleware if you want to inject an argument in a method and if this argument is not a GraphQL input type or if you want to alter the way input types are imported (for instance if you want to add a validation step) +
diff --git a/website/versioned_docs/version-7.0.0/laravel-package-advanced.mdx b/website/versioned_docs/version-7.0.0/laravel-package-advanced.mdx new file mode 100644 index 0000000000..4f63bedcf8 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/laravel-package-advanced.mdx @@ -0,0 +1,343 @@ +--- +id: laravel-package-advanced +title: "Laravel package: advanced usage" +sidebar_label: Laravel specific features +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +
+ Be advised! This documentation will be removed in a future release. For current and up-to-date Laravel extension specific documentation, please see the Github repository. +
+ +The Laravel package comes with a number of features to ease the integration of GraphQLite in Laravel. + +## Support for Laravel validation rules + +The GraphQLite Laravel package comes with a special `@Validate` annotation to use Laravel validation rules in your +input types. + + + + +```php +use TheCodingMachine\GraphQLite\Laravel\Annotations\Validate; + +class MyController +{ + #[Mutation] + public function createUser( + #[Validate("email|unique:users")] + string $email, + #[Validate("gte:8")] + string $password + ): User + { + // ... + } +} +``` + + + + +```php +use TheCodingMachine\GraphQLite\Laravel\Annotations\Validate; + +class MyController +{ + /** + * @Mutation + * @Validate(for="$email", rule="email|unique:users") + * @Validate(for="$password", rule="gte:8") + */ + public function createUser(string $email, string $password): User + { + // ... + } +} +``` + + + + +You can use the `@Validate` annotation in any query / mutation / field / factory / decorator. + +If a validation fails to pass, the message will be printed in the "errors" section and you will get a HTTP 400 status code: + +```json +{ + "errors": [ + { + "message": "The email must be a valid email address.", + "extensions": { + "argument": "email", + "category": "Validate" + } + }, + { + "message": "The password must be greater than or equal 8 characters.", + "extensions": { + "argument": "password", + "category": "Validate" + } + } + ] +} +``` + +You can use any validation rule described in [the Laravel documentation](https://laravel.com/docs/6.x/validation#available-validation-rules) + +## Support for pagination + +In your query, if you explicitly return an object that extends the `Illuminate\Pagination\LengthAwarePaginator` class, +the query result will be wrapped in a "paginator" type. + + + + +```php +class MyController +{ + /** + * @return Product[] + */ + #[Query] + public function products(): Illuminate\Pagination\LengthAwarePaginator + { + return Product::paginate(15); + } +} +``` + + + + +```php +class MyController +{ + /** + * @Query + * @return Product[] + */ + public function products(): Illuminate\Pagination\LengthAwarePaginator + { + return Product::paginate(15); + } +} +``` + + + + + +Notice that: + +- the method return type MUST BE `Illuminate\Pagination\LengthAwarePaginator` or a class extending `Illuminate\Pagination\LengthAwarePaginator` +- you MUST add a `@return` statement to help GraphQLite find the type of the list + +Once this is done, you can get plenty of useful information about this page: + +```graphql +products { + items { # The items for the selected page + id + name + } + totalCount # The total count of items. + lastPage # Get the page number of the last available page. + firstItem # Get the "index" of the first item being paginated. + lastItem # Get the "index" of the last item being paginated. + hasMorePages # Determine if there are more items in the data source. + perPage # Get the number of items shown per page. + hasPages # Determine if there are enough items to split into multiple pages. + currentPage # Determine the current page being paginated. + isEmpty # Determine if the list of items is empty or not. + isNotEmpty # Determine if the list of items is not empty. +} +``` + + +
+ Be sure to type hint on the class (Illuminate\Pagination\LengthAwarePaginator) and not on the interface (Illuminate\Contracts\Pagination\LengthAwarePaginator). The interface itself is not iterable (it does not extend Traversable) and therefore, GraphQLite will refuse to iterate over it. +
+ +### Simple paginator + +Note: if you are using `simplePaginate` instead of `paginate`, you can type hint on the `Illuminate\Pagination\Paginator` class. + + + + +```php +class MyController +{ + /** + * @return Product[] + */ + #[Query] + public function products(): Illuminate\Pagination\Paginator + { + return Product::simplePaginate(15); + } +} +``` + + + + +```php +class MyController +{ + /** + * @Query + * @return Product[] + */ + public function products(): Illuminate\Pagination\Paginator + { + return Product::simplePaginate(15); + } +} +``` + + + + +The behaviour will be exactly the same except you will be missing the `totalCount` and `lastPage` fields. + +## Using GraphQLite with Eloquent efficiently + +In GraphQLite, you are supposed to put a `@Field` annotation on each getter. + +Eloquent uses PHP magic properties to expose your database records. +Because Eloquent relies on magic properties, it is quite rare for an Eloquent model to have proper getters and setters. + +So we need to find a workaround. GraphQLite comes with a `@MagicField` annotation to help you +working with magic properties. + + + + +```php +#[Type] +#[MagicField(name: "id", outputType: "ID!")] +#[MagicField(name: "name", phpType: "string")] +#[MagicField(name: "categories", phpType: "Category[]")] +class Product extends Model +{ +} +``` + + + + +```php +/** + * @Type() + * @MagicField(name="id", outputType="ID!") + * @MagicField(name="name", phpType="string") + * @MagicField(name="categories", phpType="Category[]") + */ +class Product extends Model +{ +} +``` + + + + +Please note that since the properties are "magic", they don't have a type. Therefore, +you need to pass either the "outputType" attribute with the GraphQL type matching the property, +or the "phpType" attribute with the PHP type matching the property. + +### Pitfalls to avoid with Eloquent + +When designing relationships in Eloquent, you write a method to expose that relationship this way: + +```php +class User extends Model +{ + /** + * Get the phone record associated with the user. + */ + public function phone() + { + return $this->hasOne('App\Phone'); + } +} +``` + +It would be tempting to put a `@Field` annotation on the `phone()` method, but this will not work. Indeed, +the `phone()` method does not return a `App\Phone` object. It is the `phone` magic property that returns it. + +In short: + +
+ This does not work: + +```php +class User extends Model +{ + /** + * @Field + */ + public function phone() + { + return $this->hasOne('App\Phone'); + } +} +``` + +
+ +
+ This works: + +```php +/** + * @MagicField(name="phone", phpType="App\\Phone") + */ +class User extends Model +{ + public function phone() + { + return $this->hasOne('App\Phone'); + } +} +``` + +
+ +## Export the schema from the CLI + +The extension comes with a special command: `graphqlite:export-schema`. + +Usage: + +```console +$ ./artisan graphqlite:export-schema --output=schema.graphql +``` + +This will export your GraphQL schema in SDL format. You can use this exported schema to import it in other +tools (like graphql-codegen). \ No newline at end of file diff --git a/website/versioned_docs/version-7.0.0/laravel-package.md b/website/versioned_docs/version-7.0.0/laravel-package.md new file mode 100644 index 0000000000..d7014c2bcc --- /dev/null +++ b/website/versioned_docs/version-7.0.0/laravel-package.md @@ -0,0 +1,153 @@ +--- +id: laravel-package +title: Getting started with Laravel +sidebar_label: Laravel package +--- + +
+ Be advised! This documentation will be removed in a future release. For current and up-to-date Laravel extension specific documentation, please see the Github repository. +
+ +The GraphQLite-Laravel package is compatible with **Laravel 5.7+**, **Laravel 6.x** and **Laravel 7.x**. + +## Installation + +Open a terminal in your current project directory and run: + +```console +$ composer require thecodingmachine/graphqlite-laravel +``` + +If you want to publish the configuration (in order to edit it), run: + +```console +$ php artisan vendor:publish --provider="TheCodingMachine\GraphQLite\Laravel\Providers\GraphQLiteServiceProvider" +``` + +You can then configure the library by editing `config/graphqlite.php`. + +```php title="config/graphqlite.php" + 'App\\Http\\Controllers', + 'types' => 'App\\', + 'debug' => Debug::RETHROW_UNSAFE_EXCEPTIONS, + 'uri' => env('GRAPHQLITE_URI', '/graphql'), + 'middleware' => ['web'], + 'guard' => ['web'], +]; +``` + +The debug parameters are detailed in the [documentation of the Webonyx GraphQL library](https://webonyx.github.io/graphql-php/error-handling/) +which is used internally by GraphQLite. + +## Configuring CSRF protection + +
By default, the /graphql route is placed under web middleware group which requires a +CSRF token.
+ +You have 3 options: + +- Use the `api` middleware +- Disable CSRF for GraphQL routes +- or configure your GraphQL client to pass the `X-CSRF-TOKEN` with every GraphQL query + +### Use the `api` middleware + +If you plan to use graphql for server-to-server connection only, you should probably configure GraphQLite to use the +`api` middleware instead of the `web` middleware: + +```php title="config/graphqlite.php" + ['api'], + 'guard' => ['api'], +]; +``` + +### Disable CSRF for the /graphql route + +If you plan to use graphql from web browsers and if you want to explicitly allow access from external applications +(through CORS headers), you need to disable the CSRF token. + +Simply add `graphql` to `$except` in `app/Http/Middleware/VerifyCsrfToken.php`. + +### Configuring your GraphQL client + +If you are planning to use `graphql` only from your website domain, then the safest way is to keep CSRF enabled and +configure your GraphQL JS client to pass the CSRF headers on any graphql request. + +The way you do this depends on the Javascript GraphQL client you are using. + +Assuming you are using [Apollo](https://www.apollographql.com/docs/link/links/http/), you need to be sure that Apollo passes the token +back to Laravel on every request. + +```js title="Sample Apollo client setup with CSRF support" +import { ApolloClient, ApolloLink, InMemoryCache, HttpLink } from 'apollo-boost'; + +const httpLink = new HttpLink({ uri: 'https://api.example.com/graphql' }); + +const authLink = new ApolloLink((operation, forward) => { + // Retrieve the authorization token from local storage. + const token = localStorage.getItem('auth_token'); + + // Get the XSRF-TOKEN that is set by Laravel on each request + var cookieValue = document.cookie.replace(/(?:(?:^|.*;\s*)XSRF-TOKEN\s*\=\s*([^;]*).*$)|^.*$/, "$1"); + + // Use the setContext method to set the X-CSRF-TOKEN header back. + operation.setContext({ + headers: { + 'X-CSRF-TOKEN': cookieValue + } + }); + + // Call the next link in the middleware chain. + return forward(operation); +}); + +const client = new ApolloClient({ + link: authLink.concat(httpLink), // Chain it with the HttpLink + cache: new InMemoryCache() +}); +``` + +## Adding GraphQL DevTools + +GraphQLite does not include additional GraphQL tooling, such as the GraphiQL editor. +To integrate a web UI to query your GraphQL endpoint with your Laravel installation, +we recommend installing [GraphQL Playground](https://github.com/mll-lab/laravel-graphql-playground) + +```console +$ composer require mll-lab/laravel-graphql-playground +``` + +By default, the playground will be available at `/graphql-playground`. + +Or you can install [Altair GraphQL Client](https://github.com/XKojiMedia/laravel-altair-graphql) + +```console +$ composer require xkojimedia/laravel-altair-graphql +``` + +You can also use any external client with GraphQLite, make sure to point it to the URL defined in the config (`'/graphql'` by default). + +## Troubleshooting HTTP 419 errors + +If HTTP requests to GraphQL endpoint generate responses with the HTTP 419 status code, you have an issue with the configuration of your +CSRF token. Please check again [the paragraph dedicated to CSRF configuration](#configuring-csrf-protection). diff --git a/website/versioned_docs/version-7.0.0/migrating.md b/website/versioned_docs/version-7.0.0/migrating.md new file mode 100644 index 0000000000..aa181f7fcc --- /dev/null +++ b/website/versioned_docs/version-7.0.0/migrating.md @@ -0,0 +1,54 @@ +--- +id: migrating +title: Migrating +sidebar_label: Migrating +--- + +## Migrating from v4.0 to v4.1 + +GraphQLite follows Semantic Versioning. GraphQLite 4.1 is backward compatible with GraphQLite 4.0. See +[semantic versioning](semver.md) for more details. + +There is one exception though: the **ecodev/graphql-upload** package (used to get support for file uploads in GraphQL +input types) is now a "recommended" dependency only. +If you are using GraphQL file uploads, you need to add `ecodev/graphql-upload` to your `composer.json` by running this command: + +```console +$ composer require ecodev/graphql-upload +``` + +## Migrating from v3.0 to v4.0 + +If you are a "regular" GraphQLite user, migration to v4 should be straightforward: + +- Annotations are mostly untouched. The only annotation that is changed is the `@SourceField` annotation. + - Check your code for every places where you use the `@SourceField` annotation: + - The "id" attribute has been remove (`@SourceField(id=true)`). Instead, use `@SourceField(outputType="ID")` + - The "logged", "right" and "failWith" attributes have been removed (`@SourceField(logged=true)`). + Instead, use the annotations attribute with the same annotations you use for the `@Field` annotation: + `@SourceField(annotations={@Logged, @FailWith(null)})` + - If you use magic property and were creating a getter for every magic property (to put a `@Field` annotation on it), + you can now replace this getter with a `@MagicField` annotation. +- In GraphQLite v3, the default was to hide a field from the schema if a user has no access to it. + In GraphQLite v4, the default is to still show this field, but to throw an error if the user makes a query on it + (this way, the schema is the same for all users). If you want the old mode, use the new + [`@HideIfUnauthorized` annotation](annotations-reference.md#hideifunauthorized-annotation) +- If you are using the Symfony bundle, the Laravel package or the Universal module, you must also upgrade those to 4.0. + These package will take care of the wiring for you. Apart for upgrading the packages, you have nothing to do. +- If you are relying on the `SchemaFactory` to bootstrap GraphQLite, you have nothing to do. + +On the other hand, if you are a power user and if you are wiring GraphQLite services yourself (without using the +`SchemaFactory`) or if you implemented custom "TypeMappers", you will need to adapt your code: + +- The `FieldsBuilderFactory` is gone. Directly instantiate `FieldsBuilder` in v4. +- The `CompositeTypeMapper` class has no more constructor arguments. Use the `addTypeMapper` method to register + type mappers in it. +- The `FieldsBuilder` now accept an extra argument: the `RootTypeMapper` that you need to instantiate accordingly. Take + a look at the `SchemaFactory` class for an example of proper configuration. +- The `HydratorInterface` and all implementations are gone. When returning an input object from a TypeMapper, the object + must now implement the `ResolvableMutableInputInterface` (an input object type that contains its own resolver) + +Note: we strongly recommend to use the Symfony bundle, the Laravel package, the Universal module or the SchemaManager +to bootstrap GraphQLite. Wiring directly GraphQLite classes (like the `FieldsBuilder`) into your container is not recommended, +as the signature of the constructor of those classes may vary from one minor release to another. +Use the `SchemaManager` instead. diff --git a/website/versioned_docs/version-7.0.0/multiple-output-types.mdx b/website/versioned_docs/version-7.0.0/multiple-output-types.mdx new file mode 100644 index 0000000000..c404c9a5b6 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/multiple-output-types.mdx @@ -0,0 +1,256 @@ +--- +id: multiple-output-types +title: Mapping multiple output types for the same class +sidebar_label: Class with multiple output types +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +Available in GraphQLite 4.0+ + +In most cases, you have one PHP class and you want to map it to one GraphQL output type. + +But in very specific cases, you may want to use different GraphQL output type for the same class. +For instance, depending on the context, you might want to prevent the user from accessing some fields of your object. + +To do so, you need to create 2 output types for the same PHP class. You typically do this using the "default" attribute of the `@Type` annotation. + +## Example + +Here is an example. Say we are manipulating products. When I query a `Product` details, I want to have access to all fields. +But for some reason, I don't want to expose the price field of a product if I query the list of all products. + + + + + + +```php +#[Type] +class Product +{ + // ... + + #[Field] + public function getName(): string + { + return $this->name; + } + + #[Field] + public function getPrice(): ?float + { + return $this->price; + } +} +``` + + + + + + +```php +/** + * @Type() + */ +class Product +{ + // ... + + /** + * @Field() + */ + public function getName(): string + { + return $this->name; + } + + /** + * @Field() + */ + public function getPrice(): ?float + { + return $this->price; + } +} +``` + + + + + +The `Product` class is declaring a classic GraphQL output type named "Product". + + + + +```php +#[Type(class: Product::class, name: "LimitedProduct", default: false)] +#[SourceField(name: "name")] +class LimitedProductType +{ + // ... +} +``` + + + + +```php +/** + * @Type(class=Product::class, name="LimitedProduct", default=false) + * @SourceField(name="name") + */ +class LimitedProductType +{ + // ... +} +``` + + + + + + +The `LimitedProductType` also declares an ["external" type](external-type-declaration.mdx) mapping the `Product` class. +But pay special attention to the `@Type` annotation. + +First of all, we specify `name="LimitedProduct"`. This is useful to avoid having colliding names with the "Product" GraphQL output type +that is already declared. + +Then, we specify `default=false`. This means that by default, the `Product` class should not be mapped to the `LimitedProductType`. +This type will only be used when we explicitly request it. + +Finally, we can write our requests: + + + + +```php +class ProductController +{ + /** + * This field will use the default type. + */ + #[Field] + public function getProduct(int $id): Product { /* ... */ } + + /** + * Because we use the "outputType" attribute, this field will use the other type. + * + * @return Product[] + */ + #[Field(outputType: "[LimitedProduct!]!")] + public function getProducts(): array { /* ... */ } +} +``` + + + + +```php +class ProductController +{ + /** + * This field will use the default type. + * + * @Field + */ + public function getProduct(int $id): Product { /* ... */ } + + /** + * Because we use the "outputType" attribute, this field will use the other type. + * + * @Field(outputType="[LimitedProduct!]!") + * @return Product[] + */ + public function getProducts(): array { /* ... */ } +} +``` + + + + + + +Notice how the "outputType" attribute is used in the `@Field` annotation to force the output type. + +Is a result, when the end user calls the `product` query, we will have the possibility to fetch the `name` and `price` fields, +but if he calls the `products` query, each product in the list will have a `name` field but no `price` field. We managed +to successfully expose a different set of fields based on the query context. + +## Extending a non-default type + +If you want to extend a type using the `@ExtendType` annotation and if this type is declared as non-default, +you need to target the type by name instead of by class. + +So instead of writing: + + + + +```php +#[ExtendType(class: Product::class)] +``` + + + + +```php +/** + * @ExtendType(class=Product::class) + */ +``` + + + + +you will write: + + + + +```php +#[ExtendType(name: "LimitedProduct")] +``` + + + + +```php +/** + * @ExtendType(name="LimitedProduct") + */ +``` + + + + +Notice how we use the "name" attribute instead of the "class" attribute in the `@ExtendType` annotation. diff --git a/website/versioned_docs/version-7.0.0/mutations.mdx b/website/versioned_docs/version-7.0.0/mutations.mdx new file mode 100644 index 0000000000..16ab03a72a --- /dev/null +++ b/website/versioned_docs/version-7.0.0/mutations.mdx @@ -0,0 +1,60 @@ +--- +id: mutations +title: Mutations +sidebar_label: Mutations +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +In GraphQLite, mutations are created [like queries](queries.mdx). + +To create a mutation, you must annotate a method in a controller with the `@Mutation` annotation. + +For instance: + + + + +```php +namespace App\Controller; + +use TheCodingMachine\GraphQLite\Annotations\Mutation; + +class ProductController +{ + #[Mutation] + public function saveProduct(int $id, string $name, ?float $price = null): Product + { + // Some code that saves a product. + } +} +``` + + + + +```php +namespace App\Controller; + +use TheCodingMachine\GraphQLite\Annotations\Mutation; + +class ProductController +{ + /** + * @Mutation + */ + public function saveProduct(int $id, string $name, ?float $price = null): Product + { + // Some code that saves a product. + } +} +``` + + + diff --git a/website/versioned_docs/version-7.0.0/operation-complexity.md b/website/versioned_docs/version-7.0.0/operation-complexity.md new file mode 100644 index 0000000000..bf946721ee --- /dev/null +++ b/website/versioned_docs/version-7.0.0/operation-complexity.md @@ -0,0 +1,223 @@ +--- +id: operation-complexity +title: Operation complexity +sidebar_label: Operation complexity +--- + +At some point you may find yourself receiving queries with an insane amount of requested +fields or items, all at once. Usually, it's not a good thing, so you may want to somehow +limit the amount of requests or their individual complexity. + +## Query depth + +The simplest way to limit complexity is to limit the max query depth. `webonyx/graphql-php`, +which GraphQLite relies on, [has this built in](https://webonyx.github.io/graphql-php/security/#limiting-query-depth). +To use it, you may use `addValidationRule` when building your PSR15 middleware: + +```php +$builder = new Psr15GraphQLMiddlewareBuilder($schema); + +$builder->addValidationRule(new \GraphQL\Validator\Rules\QueryDepth(7)); +``` + +Although this works for simple cases, this doesn't prevent requesting an excessive amount +of fields on the depth of under 7, nor does it prevent requesting too many nodes in paginated lists. +This is where automatic query complexity comes to save us. + +## Static request analysis + +The operation complexity analyzer is a useful tool to make your API secure. The operation +complexity analyzer assigns by default every field a complexity of `1`. The complexity of +all fields in one of the operations of a GraphQL request is not allowed to be greater +than the maximum permitted operation complexity. + +This sounds fairly simple at first, but the more you think about this, the more you +wonder if that is so. Does every field have the same complexity? + +In a data graph, not every field is the same. We have fields that fetch data that are +more expensive than fields that just complete already resolved data. + +```graphql +type Query { + books(take: Int = 10): [Book] +} + +type Book { + title + author: Author +} + +type Author { + name +} +``` + +In the above example executing the `books` field on the `Query` type might go to the +database and fetch the `Book`. This means that the cost of the `books` field is +probably higher than the cost of the `title` field. The cost of the title field +might be the impact on the memory and to the transport. For `title`, the default +cost of `1` os OK. But for `books`, we might want to go with a higher cost of `10` +since we are getting a list of books from our database. + +Moreover, we have the field `author` on the book, which might go to the database +as well to fetch the `Author` object. Since we are only fetching a single item here, +we might want to apply a cost of `5` to this field. + +```php +class Controller { + /** + * @return Book[] + */ + #[Query] + #[Cost(complexity: 10)] + public function books(int $take = 10): array {} +} + +#[Type] +class Book { + #[Field] + public string $title; + + #[Field] + #[Cost(complexity: 5)] + public Author $author; +} + +#[Type] +class Author { + #[Field] + public string $name; +} +``` + +If we run the following query against our data graph, we will come up with the cost of `11`. + +```graphql +query { + books { + title + } +} +``` + +When drilling in further, a cost of `17` occurs. + +```graphql +query { + books { + title + author { + name + } + } +} +``` + +This kind of analysis is entirely static and could just be done by inspecting the +query syntax tree. The impact on the overall execution performance is very low. +But with this static approach, we do have a very rough idea of the performance. +Is it correct to apply always a cost of `10` even though we might get one or one +hundred books back? + +## Full request analysis + +The operation complexity analyzer can also take arguments into account when analyzing operation complexity. + +If we look at our data graph, we can see that the `books` field actually has an argument +that defines how many books are returned. The `take` argument, in this case, specifies +the maximum books that the field will return. + +When measuring the field\`s impact, we can take the argument `take` into account as a +multiplier of our cost. This means we might want to lower the cost to `5` since now we +get a more fine-grained cost calculation by multiplying the complexity +of the field with the `take` argument. + +```php +class Controller { + /** + * @return Book[] + */ + #[Query] + #[Cost(complexity: 5, multipliers: ['take'], defaultMultiplier: 200)] + public function books(?int $take = 10): array {} +} + +#[Type] +class Book { + #[Field] + public string $title; + + #[Field] + #[Cost(complexity: 5)] + public Author $author; +} + +#[Type] +class Author { + #[Field] + public string $name; +} +``` + +With the multiplier in place, we now get a cost of `60` for the request since the multiplier +is applied to the books field and the child fields' cost. If multiple multipliers are specified, +the cost will be multiplied by each of the fields. + +Cost calculation: `10 * (5 + 1)` + +```graphql +query { + books { + title + } +} +``` + +When drilling in further, the cost will go up to `240` since we are now pulling twice as much books and also their authors. + +Cost calculation: `20 * (5 + 1 + 5 + 1)` + +```graphql +query { + books(take: 20) { + title + author { + name + } + } +} +``` + +Notice the nullable `$take` parameter. This might come in handy if `take: null` means "get all items", +but that would also mean that the overall complexity would only be `1 + 5 + 1 + 5 + 1 = 11`, +when in fact that would be a very costly query to execute. + +If all of the multiplier fields are either `null` or missing (and don't have default values), +`defaultMultiplier` is used: + +Cost calculation: `200 * (5 + 1 + 5 + 1)` + +```graphql +query { + books(take: null) { + title + author { + name + } + } +} +``` + +## Setup + +As with query depth, automatic query complexity is configured through PSR15 middleware: + +```php +$builder = new Psr15GraphQLMiddlewareBuilder($schema); + +// Total query cost cannot exceed 1000 points +$builder->limitQueryComplexity(1000); +``` + +Beware that introspection queries would also be limited in complexity. A full introspection +query sits at around `107` points, so we recommend a minimum of `150` for query complexity limit. diff --git a/website/versioned_docs/version-7.0.0/other-frameworks.mdx b/website/versioned_docs/version-7.0.0/other-frameworks.mdx new file mode 100644 index 0000000000..64806bd84a --- /dev/null +++ b/website/versioned_docs/version-7.0.0/other-frameworks.mdx @@ -0,0 +1,342 @@ +--- +id: other-frameworks +title: Getting started with any framework +sidebar_label: "Other frameworks / No framework" +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Installation + +Open a terminal in your current project directory and run: + +```console +$ composer require thecodingmachine/graphqlite +``` + +## Requirements + +In order to bootstrap GraphQLite, you will need: + +- A PSR-11 compatible container +- A PSR-16 cache + +Additionally, you will have to route the HTTP requests to the underlying GraphQL library. + +GraphQLite relies on the [webonyx/graphql-php](http://webonyx.github.io/graphql-php/) library internally. +This library plays well with PSR-7 requests and we also provide a [PSR-15 middleware](#psr-15-middleware). + +## Integration + +Webonyx/graphql-php library requires a [Schema](https://webonyx.github.io/graphql-php/type-system/schema/) in order to resolve +GraphQL queries. We provide a `SchemaFactory` class to create such a schema: + +```php +use TheCodingMachine\GraphQLite\SchemaFactory; + +// $cache is a PSR-16 compatible cache +// $container is a PSR-11 compatible container +$factory = new SchemaFactory($cache, $container); +$factory->addControllerNamespace('App\\Controllers\\') + ->addTypeNamespace('App\\'); + +$schema = $factory->createSchema(); +``` + +You can now use this schema with [Webonyx GraphQL facade](https://webonyx.github.io/graphql-php/getting-started/#hello-world) +or the [StandardServer class](https://webonyx.github.io/graphql-php/executing-queries/#using-server). + +The `SchemaFactory` class also comes with a number of methods that you can use to customize your GraphQLite settings. + +```php +// Configure an authentication service (to resolve the @Logged annotations). +$factory->setAuthenticationService(new VoidAuthenticationService()); +// Configure an authorization service (to resolve the @Right annotations). +$factory->setAuthorizationService(new VoidAuthorizationService()); +// Change the naming convention of GraphQL types globally. +$factory->setNamingStrategy(new NamingStrategy()); +// Add a custom type mapper. +$factory->addTypeMapper($typeMapper); +// Add a custom type mapper using a factory to create it. +// Type mapper factories are useful if you need to inject the "recursive type mapper" into your type mapper constructor. +$factory->addTypeMapperFactory($typeMapperFactory); +// Add a root type mapper. +$factory->addRootTypeMapper($rootTypeMapper); +// Add a parameter mapper. +$factory->addParameterMapper($parameterMapper); +// Add a query provider. These are used to find queries and mutations in the application. +$factory->addQueryProvider($queryProvider); +// Add a query provider using a factory to create it. +// Query provider factories are useful if you need to inject the "fields builder" into your query provider constructor. +$factory->addQueryProviderFactory($queryProviderFactory); +// Set a default InputType validator service to handle validation on all `Input` annotated types +$factory->setInputTypeValidator($validator); +// Add custom options to the Webonyx underlying Schema. +$factory->setSchemaConfig($schemaConfig); +// Configures the time-to-live for the GraphQLite cache. Defaults to 2 seconds in dev mode. +$factory->setGlobTtl(2); +// Enables prod-mode (cache settings optimized for best performance). +// This is a shortcut for `$schemaFactory->setGlobTtl(null)` +$factory->prodMode(); +// Enables dev-mode (this is the default mode: cache settings optimized for best developer experience). +// This is a shortcut for `$schemaFactory->setGlobTtl(2)` +$factory->devMode(); +``` + +### GraphQLite context + +Webonyx allows you pass a "context" object when running a query. +For some GraphQLite features to work (namely: the prefetch feature), GraphQLite needs you to initialize the Webonyx context +with an instance of the `TheCodingMachine\GraphQLite\Context\Context` class. + +For instance: + +```php +use TheCodingMachine\GraphQLite\Context\Context; + +$result = GraphQL::executeQuery($schema, $query, null, new Context(), $variableValues); +``` + +## Minimal example + +The smallest working example using no framework is: + +```php +addControllerNamespace('App\\Controllers\\') + ->addTypeNamespace('App\\'); + +$schema = $factory->createSchema(); + +$rawInput = file_get_contents('php://input'); +$input = json_decode($rawInput, true); +$query = $input['query']; +$variableValues = isset($input['variables']) ? $input['variables'] : null; + +$result = GraphQL::executeQuery($schema, $query, null, new Context(), $variableValues); +$output = $result->toArray(); + +header('Content-Type: application/json'); +echo json_encode($output); +``` + +## PSR-15 Middleware + +When using a framework, you will need a way to route your HTTP requests to the `webonyx/graphql-php` library. + +If the framework you are using is compatible with PSR-15 (like Slim PHP or Zend-Expressive / Laminas), GraphQLite +comes with a PSR-15 middleware out of the box. + +In order to get an instance of this middleware, you can use the `Psr15GraphQLMiddlewareBuilder` builder class: + +```php +// $schema is an instance of the GraphQL schema returned by SchemaFactory::createSchema (see previous chapter) +$builder = new Psr15GraphQLMiddlewareBuilder($schema); + +$middleware = $builder->createMiddleware(); + +// You can now inject your middleware in your favorite PSR-15 compatible framework. +// For instance: +$zendMiddlewarePipe->pipe($middleware); +``` + +The builder offers a number of setters to modify its behaviour: + +```php +$builder->setUrl("/graphql"); // Modify the URL endpoint (defaults to /graphql) + +$config = $builder->getConfig(); // Returns a Webonyx ServerConfig object. +// Define your own formatter and error handlers for Webonyx. +$config->setErrorFormatter([ExceptionHandler::class, 'errorFormatter']); +$config->setErrorsHandler([ExceptionHandler::class, 'errorHandler']); + +$builder->setConfig($config); + +$builder->setResponseFactory(new ResponseFactory()); // Set a PSR-18 ResponseFactory (not needed if you are using zend-framework/zend-diactoros ^2 +$builder->setStreamFactory(new StreamFactory()); // Set a PSR-18 StreamFactory (not needed if you are using zend-framework/zend-diactoros ^2 +$builder->setHttpCodeDecider(new HttpCodeDecider()); // Set a class in charge of deciding the HTTP status code based on the response. + +// Configure the server to use Apollo automatic persisted queries with given cache and an optional time-to-live. +// See https://www.apollographql.com/docs/apollo-server/performance/apq/ +$builder->useAutomaticPersistedQueries($cache, new DateInterval('PT1H')); +``` + +### Example + +In this example, we will focus on getting a working version of GraphQLite using: + +- [Laminas Stratigility](https://docs.laminas.dev/laminas-stratigility/) as a PSR-15 server +- `mouf/picotainer` (a micro-container) for the PSR-11 container +- `symfony/cache ` for the PSR-16 cache + +The choice of the libraries is really up to you. You can adapt it based on your needs. + +```json title="composer.json" +{ + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "require": { + "thecodingmachine/graphqlite": "^4", + "laminas/laminas-diactoros": "^2", + "laminas/laminas-stratigility": "^3", + "laminas/laminas-httphandlerrunner": "^2", + "mouf/picotainer": "^1.1", + "symfony/cache": "^4.2" + }, + "minimum-stability": "dev", + "prefer-stable": true +} +``` + +```php title="index.php" +get(MiddlewarePipe::class), + new SapiStreamEmitter(), + $serverRequestFactory, + $errorResponseGenerator +); +$runner->run(); +``` + +Here we are initializing a Laminas `RequestHandler` (it receives requests) and we pass it to a Laminas Stratigility `MiddlewarePipe`. +This `MiddlewarePipe` comes from the container declared in the `config/container.php` file: + +```php title="config/container.php" + function(ContainerInterface $container) { + $pipe = new MiddlewarePipe(); + $pipe->pipe($container->get(WebonyxGraphqlMiddleware::class)); + return $pipe; + }, + // The WebonyxGraphqlMiddleware is a PSR-15 compatible + // middleware that exposes Webonyx schemas. + WebonyxGraphqlMiddleware::class => function(ContainerInterface $container) { + $builder = new Psr15GraphQLMiddlewareBuilder($container->get(Schema::class)); + return $builder->createMiddleware(); + }, + CacheInterface::class => function() { + return new ApcuCache(); + }, + Schema::class => function(ContainerInterface $container) { + // The magic happens here. We create a schema using GraphQLite SchemaFactory. + $factory = new SchemaFactory($container->get(CacheInterface::class), $container); + $factory->addControllerNamespace('App\\Controllers\\'); + $factory->addTypeNamespace('App\\'); + return $factory->createSchema(); + } +]); +``` + +Now, we need to add a first query and therefore create a controller. +The application will look into the `App\Controllers` namespace for GraphQLite controllers. + +It assumes that the container has an entry whose name is the controller's fully qualified class name. + + + + + +```php title="src/Controllers/MyController.php" +namespace App\Controllers; + +use TheCodingMachine\GraphQLite\Annotations\Query; + +class MyController +{ + #[Query] + public function hello(string $name): string + { + return 'Hello '.$name; + } +} +``` + + + + +```php title="src/Controllers/MyController.php" +namespace App\Controllers; + +use TheCodingMachine\GraphQLite\Annotations\Query; + +class MyController +{ + /** + * @Query + */ + public function hello(string $name): string + { + return 'Hello '.$name; + } +} +``` + + + + + +```php title="config/container.php" +use App\Controllers\MyController; + +return new Picotainer([ + // ... + + // We declare the controller in the container. + MyController::class => function() { + return new MyController(); + }, +]); +``` + +And we are done! You can now test your query using your favorite GraphQL client. diff --git a/website/versioned_docs/version-7.0.0/pagination.mdx b/website/versioned_docs/version-7.0.0/pagination.mdx new file mode 100644 index 0000000000..66b58c863b --- /dev/null +++ b/website/versioned_docs/version-7.0.0/pagination.mdx @@ -0,0 +1,99 @@ +--- +id: pagination +title: Paginating large result sets +sidebar_label: Pagination +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +It is quite common to have to paginate over large result sets. + +GraphQLite offers a simple way to do that using [Porpaginas](https://github.com/beberlei/porpaginas). + +Porpaginas is a set of PHP interfaces that can be implemented by result iterators. It comes with a native support for +PHP arrays, Doctrine and [TDBM](https://thecodingmachine.github.io/tdbm/doc/limit_offset_resultset.html). + +
If you are a Laravel user, Eloquent does not come with a Porpaginas +iterator. However, the GraphQLite Laravel bundle comes with its own pagination system.
+ +## Installation + +You will need to install the [Porpaginas](https://github.com/beberlei/porpaginas) library to benefit from this feature. + +```bash +$ composer require beberlei/porpaginas +``` + +## Usage + +In your query, simply return a class that implements `Porpaginas\Result`: + + + + +```php +class MyController +{ + /** + * @return Product[] + */ + #[Query] + public function products(): Porpaginas\Result + { + // Some code that returns a list of products + + // If you are using Doctrine, something like: + return new Porpaginas\Doctrine\ORM\ORMQueryResult($doctrineQuery); + } +} +``` + + + + +```php +class MyController +{ + /** + * @Query + * @return Product[] + */ + public function products(): Porpaginas\Result + { + // Some code that returns a list of products + + // If you are using Doctrine, something like: + return new Porpaginas\Doctrine\ORM\ORMQueryResult($doctrineQuery); + } +} +``` + + + + +Notice that: + +- the method return type MUST BE `Porpaginas\Result` or a class implementing `Porpaginas\Result` +- you MUST add a `@return` statement to help GraphQLite find the type of the list + +Once this is done, you can paginate directly from your GraphQL query: + +```graphql +products { + items(limit: 10, offset: 20) { + id + name + } + count +} +``` + +Results are wrapped into an item field. You can use the "limit" and "offset" parameters to apply pagination automatically. + +The "count" field returns the **total count** of items. diff --git a/website/versioned_docs/version-7.0.0/prefetch-method.mdx b/website/versioned_docs/version-7.0.0/prefetch-method.mdx new file mode 100644 index 0000000000..1c24e7099f --- /dev/null +++ b/website/versioned_docs/version-7.0.0/prefetch-method.mdx @@ -0,0 +1,198 @@ +--- +id: prefetch-method +title: Prefetching records +sidebar_label: Prefetching records +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## The problem + +GraphQL naive implementations often suffer from the "N+1" problem. + +Consider a request where a user attached to a post must be returned: + +```graphql +{ + posts { + id + user { + id + } + } +} +``` + +A naive implementation will do this: + +- 1 query to fetch the list of posts +- 1 query per post to fetch the user + +Assuming we have "N" posts, we will make "N+1" queries. + +There are several ways to fix this problem. +Assuming you are using a relational database, one solution is to try to look +ahead and perform only one query with a JOIN between "posts" and "users". +This method is described in the ["analyzing the query plan" documentation](query-plan.mdx). + +But this can be difficult to implement. This is also only useful for relational databases. If your data comes from a +NoSQL database or from the cache, this will not help. + +Instead, GraphQLite offers an easier to implement solution: the ability to fetch all fields from a given type at once. + +## The "prefetch" method + + + + +```php +#[Type] +class PostType { + /** + * @param mixed $prefetchedUsers + * @return User + */ + #[Field] + public function getUser(#[Prefetch("prefetchUsers")] $prefetchedUsers): User + { + // This method will receive the $prefetchedUsers as first argument. This is the return value of the "prefetchUsers" method below. + // Using this prefetched list, it should be easy to map it to the post + } + + /** + * @param Post[] $posts + * @return mixed + */ + public static function prefetchUsers(iterable $posts) + { + // This function is called only once per GraphQL request + // with the list of posts. You can fetch the list of users + // associated with this posts in a single request, + // for instance using a "IN" query in SQL or a multi-fetch + // in your cache back-end. + } +} +``` + + + + +```php +/** + * @Type + */ +class PostType { + /** + * @Field(prefetchMethod="prefetchUsers") + * @param mixed $prefetchedUsers + * @return User + */ + public function getUser($prefetchedUsers): User + { + // This method will receive the $prefetchedUsers as first argument. This is the return value of the "prefetchUsers" method below. + // Using this prefetched list, it should be easy to map it to the post + } + + /** + * @param Post[] $posts + * @return mixed + */ + public function prefetchUsers(iterable $posts) + { + // This function is called only once per GraphQL request + // with the list of posts. You can fetch the list of users + // associated with this posts in a single request, + // for instance using a "IN" query in SQL or a multi-fetch + // in your cache back-end. + } +} +``` + + + + + +When a "#[Prefetch]" attribute is detected on a parameter of "@Field" annotation, the method is called automatically. +The prefetch callable must be one of the following: + - a static method in the same class: `#[Prefetch('prefetchMethod')]` + - a static method in a different class: `#[Prefetch([OtherClass::class, 'prefetchMethod')]` + - a non-static method in a different class, resolvable through the container: `#[Prefetch([OtherService::class, 'prefetchMethod'])]` +The first argument of the method is always an array of instances of the main type. It can return absolutely anything (mixed). + +## Input arguments + +Field arguments can be set either on the @Field annotated method OR/AND on the prefetch methods. + +For instance: + + + + +```php +#[Type] +class PostType { + /** + * @param mixed $prefetchedComments + * @return Comment[] + */ + #[Field] + public function getComments(#[Prefetch("prefetchComments")] $prefetchedComments): array + { + // ... + } + + /** + * @param Post[] $posts + * @return mixed + */ + public static function prefetchComments(iterable $posts, bool $hideSpam, int $filterByScore) + { + // Parameters passed after the first parameter (hideSpam, filterByScore...) are automatically exposed + // as GraphQL arguments for the "comments" field. + } +} +``` + + + + +```php +/** + * @Type + */ +class PostType { + /** + * @Field(prefetchMethod="prefetchComments") + * @param mixed $prefetchedComments + * @return Comment[] + */ + public function getComments($prefetchedComments): array + { + // ... + } + + /** + * @param Post[] $posts + * @return mixed + */ + public function prefetchComments(iterable $posts, bool $hideSpam, int $filterByScore) + { + // Parameters passed after the first parameter (hideSpam, filterByScore...) are automatically exposed + // as GraphQL arguments for the "comments" field. + } +} +``` + + + diff --git a/website/versioned_docs/version-7.0.0/queries.mdx b/website/versioned_docs/version-7.0.0/queries.mdx new file mode 100644 index 0000000000..34abab54dd --- /dev/null +++ b/website/versioned_docs/version-7.0.0/queries.mdx @@ -0,0 +1,251 @@ +--- +id: queries +title: Queries +sidebar_label: Queries +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +In GraphQLite, GraphQL queries are created by writing methods in *controller* classes. + +Those classes must be in the controllers namespaces which has been defined when you configured GraphQLite. +For instance, in Symfony, the controllers namespace is `App\Controller` by default. + +## Simple query + +In a controller class, each query method must be annotated with the `@Query` annotation. For instance: + + + + +```php +namespace App\Controller; + +use TheCodingMachine\GraphQLite\Annotations\Query; + +class MyController +{ + #[Query] + public function hello(string $name): string + { + return 'Hello ' . $name; + } +} +``` + + + + +```php +namespace App\Controller; + +use TheCodingMachine\GraphQLite\Annotations\Query; + +class MyController +{ + /** + * @Query + */ + public function hello(string $name): string + { + return 'Hello ' . $name; + } +} +``` + + + + +This query is equivalent to the following [GraphQL type language](https://graphql.org/learn/schema/#type-language): + +```graphql +Type Query { + hello(name: String!): String! +} +``` + +As you can see, GraphQLite will automatically do the mapping between PHP types and GraphQL types. + +
Heads up! If you are not using a framework with an autowiring container (like Symfony or Laravel), please be aware that the MyController class must exist in the container of your application. Furthermore, the identifier of the controller in the container MUST be the fully qualified class name of controller.
+ +## About annotations / attributes + +GraphQLite relies a lot on annotations (we call them attributes since PHP 8). + +It supports both the old "Doctrine annotations" style (`@Query`) and the new PHP 8 attributes (`#[Query]`). + +Read the [Doctrine annotations VS attributes](doctrine-annotations-attributes.mdx) documentation if you are not familiar with this concept. + +## Testing the query + +The default GraphQL endpoint is `/graphql`. + +The easiest way to test a GraphQL endpoint is to use [GraphiQL](https://github.com/graphql/graphiql) or +[Altair](https://altair.sirmuel.design/) clients (they are available as Chrome or Firefox plugins) + +
+ If you are using the Symfony bundle, GraphiQL is also directly embedded.
+ Simply head to http://[path-to-my-app]/graphiql +
+ +Here a query using our simple *Hello World* example: + +![](/img/query1.png) + +## Query with a type + +So far, we simply declared a query. But we did not yet declare a type. + +Let's assume you want to return a product: + + + + +```php +namespace App\Controller; + +use TheCodingMachine\GraphQLite\Annotations\Query; + +class ProductController +{ + #[Query] + public function product(string $id): Product + { + // Some code that looks for a product and returns it. + } +} +``` + + + + +```php +namespace App\Controller; + +use TheCodingMachine\GraphQLite\Annotations\Query; + +class ProductController +{ + /** + * @Query + */ + public function product(string $id): Product + { + // Some code that looks for a product and returns it. + } +} +``` + + + + + + +As the `Product` class is not a scalar type, you must tell GraphQLite how to handle it: + + + + +```php +namespace App\Entities; + +use TheCodingMachine\GraphQLite\Annotations\Field; +use TheCodingMachine\GraphQLite\Annotations\Type; + +#[Type] +class Product +{ + // ... + + #[Field] + public function getName(): string + { + return $this->name; + } + + #[Field] + public function getPrice(): ?float + { + return $this->price; + } +} +``` + + + + +```php +namespace App\Entities; + +use TheCodingMachine\GraphQLite\Annotations\Field; +use TheCodingMachine\GraphQLite\Annotations\Type; + +/** + * @Type() + */ +class Product +{ + // ... + + /** + * @Field() + */ + public function getName(): string + { + return $this->name; + } + + /** + * @Field() + */ + public function getPrice(): ?float + { + return $this->price; + } +} +``` + + + + +The `@Type` annotation is used to inform GraphQLite that the `Product` class is a GraphQL type. + +The `@Field` annotation is used to define the GraphQL fields. This annotation must be put on a **public method**. + +The `Product` class must be in one of the *types* namespaces. As for *controller* classes, you configured this namespace when you installed +GraphQLite. By default, in Symfony, the allowed types namespaces are `App\Entity` and `App\Types`. + +This query is equivalent to the following [GraphQL type language](https://graphql.org/learn/schema/#type-language): + +```graphql +Type Product { + name: String! + price: Float +} +``` + +
+

If you are used to Domain driven design, you probably + realize that the Product class is part of your domain.

+

GraphQL annotations are adding some serialization logic that is out of scope of the domain. + These are just annotations and for most project, this is the fastest and easiest route.

+

If you feel that GraphQL annotations do not belong to the domain, or if you cannot modify the class + directly (maybe because it is part of a third party library), there is another way to create types without annotating + the domain class. We will explore that in the next chapter.

+
diff --git a/website/versioned_docs/version-7.0.0/query-plan.mdx b/website/versioned_docs/version-7.0.0/query-plan.mdx new file mode 100644 index 0000000000..98838399d0 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/query-plan.mdx @@ -0,0 +1,109 @@ +--- +id: query-plan +title: Query plan +sidebar_label: Query plan +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## The problem + +GraphQL naive implementations often suffer from the "N+1" problem. + +Let's have a look at the following query: + +```graphql +{ + products { + name + manufacturer { + name + } + } +} +``` + +A naive implementation will do this: + +- 1 query to fetch the list of products +- 1 query per product to fetch the manufacturer + +Assuming we have "N" products, we will make "N+1" queries. + +There are several ways to fix this problem. Assuming you are using a relational database, one solution is to try to look +ahead and perform only one query with a JOIN between "products" and "manufacturers". + +But how do I know if I should make the JOIN between "products" and "manufacturers" or not? I need to know ahead +of time. + +With GraphQLite, you can answer this question by tapping into the `ResolveInfo` object. + +## Fetching the query plan + +Available in GraphQLite 4.0+ + + + + +```php +use GraphQL\Type\Definition\ResolveInfo; + +class ProductsController +{ + /** + * @return Product[] + */ + #[Query] + public function products(ResolveInfo $info): array + { + if (isset($info->getFieldSelection()['manufacturer']) { + // Let's perform a request with a JOIN on manufacturer + } else { + // Let's perform a request without a JOIN on manufacturer + } + // ... + } +} +``` + + + + +```php +use GraphQL\Type\Definition\ResolveInfo; + +class ProductsController +{ + /** + * @Query + * @return Product[] + */ + public function products(ResolveInfo $info): array + { + if (isset($info->getFieldSelection()['manufacturer']) { + // Let's perform a request with a JOIN on manufacturer + } else { + // Let's perform a request without a JOIN on manufacturer + } + // ... + } +} +``` + + + + + +`ResolveInfo` is a class provided by Webonyx/GraphQL-PHP (the low-level GraphQL library used by GraphQLite). +It contains info about the query and what fields are requested. Using `ResolveInfo::getFieldSelection` you can analyze the query +and decide whether you should perform additional "JOINS" in your query or not. + +
As of the writing of this documentation, the ResolveInfo class is useful but somewhat limited. +The next version of Webonyx/GraphQL-PHP will add a "query plan" +that allows a deeper analysis of the query.
diff --git a/website/versioned_docs/version-7.0.0/semver.md b/website/versioned_docs/version-7.0.0/semver.md new file mode 100644 index 0000000000..12931f26a5 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/semver.md @@ -0,0 +1,45 @@ +--- +id: semver +title: Our backward compatibility promise +sidebar_label: Semantic versioning +--- + +Ensuring smooth upgrades of your project is a priority. That's why we promise you backward compatibility (BC) for all minor GraphQLite releases. You probably recognize this strategy as [Semantic Versioning](https://semver.org/). In short, Semantic Versioning means that only major releases (such as 4.0, 5.0 etc.) are allowed to break backward compatibility. Minor releases (such as 4.0, 4.1 etc.) may introduce new features, but must do so without breaking the existing API of that release branch (4.x in the previous example). + +But sometimes, a new feature is not quite "dry" and we need a bit of time to find the perfect API. +In such cases, we prefer to gather feedback from real-world usage, adapt the API, or remove it altogether. +Doing so is not possible with a no BC-break approach. + +To avoid being bound to our backward compatibility promise, such features can be marked as **unstable** or **experimental** and their classes and methods are marked with the `@unstable` or `@experimental` tag. + +`@unstable` or `@experimental` classes / methods will **not break** in a patch release, but *may be broken* in a minor version. + +As a rule of thumb: + +- If you are a GraphQLite user (using GraphQLite mainly through its annotations), we guarantee strict semantic versioning +- If you are extending GraphQLite features (if you are developing custom annotations, or if you are developing a GraphQlite integration + with a framework...), be sure to check the tags. + +Said otherwise: + +- If you are a GraphQLite user, in your `composer.json`, target a major version: + + ```json + { + "require": { + "thecodingmachine/graphqlite": "^4" + } + } + ``` + +- If you are extending the GraphQLite ecosystem, in your `composer.json`, target a minor version: + + ```json + { + "require": { + "thecodingmachine/graphqlite": "~4.1.0" + } + } + ``` + +Finally, classes / methods annotated with the `@internal` annotation are not meant to be used in your code or third-party library. They are meant for GraphQLite internal usage and they may break anytime. Do not use those directly. diff --git a/website/versioned_docs/version-7.0.0/subscriptions.mdx b/website/versioned_docs/version-7.0.0/subscriptions.mdx new file mode 100644 index 0000000000..bcfd118aac --- /dev/null +++ b/website/versioned_docs/version-7.0.0/subscriptions.mdx @@ -0,0 +1,53 @@ +--- +id: subscriptions +title: Subscriptions +sidebar_label: Subscriptions +--- + +In GraphQLite, subscriptions are created [like queries](queries.mdx) or [mutations](mutations.mdx). + +To create a subscription, you must annotate a method in a controller with the `#[Subscription]` attribute. + +For instance: + +```php +namespace App\Controller; + +use TheCodingMachine\GraphQLite\Annotations\Mutation; + +class ProductController +{ + #[Subscription(outputType: 'Product')] + public function productAdded(?ID $categoryId = null): void + { + // Some code that sets up any connections, stores the subscription details, etc. + } +} +``` + +As you will notice in the above example, we're returning `void`. In general, this is probably the +correct return type. + +You could, however, type the `Product` as the return type of the method, instead +of using the `outputType` argument on the `#[Subscription]` attribute. This means you +would have to return an instance of `Product` from the method though. One exception here, is if +you intend to use PHP for your long-running streaming process, you could block the process inside +the controller and basically never return anything from the method, just terminating the +connection/stream when it breaks, or when the client disconnects. + +Most implementations will want to offload the actual real-time streaming connection to a better suited +technology, like SSE (server-sent events), WebSockets, etc. GraphQLite does not make any assumptions +here. Therefore, it's most practical to return `void` from the controller method. Since GraphQL +is a strictly typed spec, we cannot return anything other than the defined `outputType` from the request. +That would be a violation of the GraphQL specification. Returning `void`, which is translated to `null` +in the GraphQL response body, allows for us to complete the request and terminate the PHP process. + +We recommend using response headers to pass back any necessary information realted to the subscription. +This might be a subscription ID, a streaming server URL to connect to, or whatever you need to pass +back to the client. + +
+ In the future, it may make sense to implement streaming servers directly into GraphQLite, especially + as PHP progresses with async and parallel processing. At this time, we might consider returning a + `Generator` (or `Fiber`) from the controller method. +
diff --git a/website/versioned_docs/version-7.0.0/symfony-bundle-advanced.mdx b/website/versioned_docs/version-7.0.0/symfony-bundle-advanced.mdx new file mode 100644 index 0000000000..d96b9fae68 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/symfony-bundle-advanced.mdx @@ -0,0 +1,229 @@ +--- +id: symfony-bundle-advanced +title: "Symfony bundle: advanced usage" +sidebar_label: Symfony specific features +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +
+ Be advised! This documentation will be removed in a future release. For current and up-to-date Symfony bundle specific documentation, please see the Github repository. +
+ +The Symfony bundle comes with a number of features to ease the integration of GraphQLite in Symfony. + +## Login and logout + +Out of the box, the GraphQLite bundle will expose a "login" and a "logout" mutation as well +as a "me" query (that returns the current user). + +If you need to customize this behaviour, you can edit the "graphqlite.security" configuration key. + +```yaml +graphqlite: + security: + enable_login: auto # Default setting + enable_me: auto # Default setting +``` + +By default, GraphQLite will enable "login" and "logout" mutations and the "me" query if the following conditions are met: + +- the "security" bundle is installed and configured (with a security provider and encoder) +- the "session" support is enabled (via the "framework.session.enabled" key). + +```yaml +graphqlite: + security: + enable_login: on +``` + +By settings `enable_login=on`, you are stating that you explicitly want the login/logout mutations. +If one of the dependencies is missing, an exception is thrown (unlike in default mode where the mutations +are silently discarded). + +```yaml +graphqlite: + security: + enable_login: off +``` + +Use the `enable_login=off` to disable the mutations. + +```yaml +graphqlite: + security: + firewall_name: main # default value +``` + +By default, GraphQLite assumes that your firewall name is "main". This is the default value used in the +Symfony security bundle so it is likely the value you are using. If for some reason you want to use +another firewall, configure the name with `graphqlite.security.firewall_name`. + +## Schema and request security + +You can disable the introspection of your GraphQL API (for instance in production mode) using +the `introspection` configuration properties. + +```yaml +graphqlite: + security: + introspection: false +``` + + +You can set the maximum complexity and depth of your GraphQL queries using the `maximum_query_complexity` +and `maximum_query_depth` configuration properties + +```yaml +graphqlite: + security: + maximum_query_complexity: 314 + maximum_query_depth: 42 +``` + +### Login using the "login" mutation + +The mutation below will log-in a user: + +```graphql +mutation login { + login(userName:"foo", password:"bar") { + userName + roles + } +} +``` + +### Get the current user with the "me" query + +Retrieving the current user is easy with the "me" query: + +```graphql +{ + me { + userName + roles + } +} +``` + +In Symfony, user objects implement `Symfony\Component\Security\Core\User\UserInterface`. +This interface is automatically mapped to a type with 2 fields: + +- `userName: String!` +- `roles: [String!]!` + +If you want to get more fields, just add the `@Type` annotation to your user class: + + + + +```php +#[Type] +class User implements UserInterface +{ + #[Field] + public function getEmail() : string + { + // ... + } + +} +``` + + + + +```php +/** + * @Type + */ +class User implements UserInterface +{ + /** + * @Field + */ + public function getEmail() : string + { + // ... + } + +} +``` + + + + +You can now query this field using an [inline fragment](https://graphql.org/learn/queries/#inline-fragments): + +```graphql +{ + me { + userName + roles + ... on User { + email + } + } +} +``` + +### Logout using the "logout" mutation + +Use the "logout" mutation to log a user out + +```graphql +mutation logout { + logout +} +``` + +## Injecting the Request + +You can inject the Symfony Request object in any query/mutation/field. + +Most of the time, getting the request object is irrelevant. Indeed, it is GraphQLite's job to parse this request and +manage it for you. Sometimes yet, fetching the request can be needed. In those cases, simply type-hint on the request +in any parameter of your query/mutation/field. + + + + +```php +use Symfony\Component\HttpFoundation\Request; + +#[Query] +public function getUser(int $id, Request $request): User +{ + // The $request object contains the Symfony Request. +} +``` + + + + +```php +use Symfony\Component\HttpFoundation\Request; + +/** + * @Query + */ +public function getUser(int $id, Request $request): User +{ + // The $request object contains the Symfony Request. +} +``` + + + diff --git a/website/versioned_docs/version-7.0.0/symfony-bundle.md b/website/versioned_docs/version-7.0.0/symfony-bundle.md new file mode 100644 index 0000000000..99822eeddd --- /dev/null +++ b/website/versioned_docs/version-7.0.0/symfony-bundle.md @@ -0,0 +1,121 @@ +--- +id: symfony-bundle +title: Getting started with Symfony +sidebar_label: Symfony bundle +--- + +
+ Be advised! This documentation will be removed in a future release. For current and up-to-date Symfony bundle specific documentation, please see the Github repository. +
+ +The GraphQLite bundle is compatible with **Symfony 4.x** and **Symfony 5.x**. + +## Applications that use Symfony Flex + +Open a command console, enter your project directory and execute: + +```console +$ composer require thecodingmachine/graphqlite-bundle +``` + +Now, go to the `config/packages/graphqlite.yaml` file and edit the namespaces to match your application. + +```yaml title="config/packages/graphqlite.yaml" +graphqlite: + namespace: + # The namespace(s) that will store your GraphQLite controllers. + # It accept either a string or a list of strings. + controllers: App\GraphQLController\ + # The namespace(s) that will store your GraphQL types and factories. + # It accept either a string or a list of strings. + types: + - App\Types\ + - App\Entity\ +``` + +More advanced parameters are detailed in the ["advanced configuration" section](#advanced-configuration) + +## Applications that don't use Symfony Flex + +Open a terminal in your current project directory and run: + +```console +$ composer require thecodingmachine/graphqlite-bundle +``` + +Enable the library by adding it to the list of registered bundles in the `app/AppKernel.php` file: + + +```php title="app/AppKernel.php" + + Do not put your GraphQL controllers in the App\Controller namespace Symfony applies a particular compiler pass to classes in the App\Controller namespace. This compiler pass will prevent you from using input types. Put your controllers in another namespace. We advise using App\GraphqlController. + + +The Symfony bundle come with a set of advanced features that are not described in this install documentation (like providing a login/logout mutation out of the box). Jump to the ["Symfony specific features"](symfony-bundle-advanced.mdx) documentation of GraphQLite if you want to learn more. diff --git a/website/versioned_docs/version-7.0.0/troubleshooting.md b/website/versioned_docs/version-7.0.0/troubleshooting.md new file mode 100644 index 0000000000..862cf9b4e7 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/troubleshooting.md @@ -0,0 +1,25 @@ +--- +id: troubleshooting +title: Troubleshooting +sidebar_label: Troubleshooting +--- + +**Error: Maximum function nesting level of '100' reached** + +Webonyx's GraphQL library tends to use a very deep stack. +This error does not necessarily mean your code is going into an infinite loop. +Simply try to increase the maximum allowed nesting level in your XDebug conf: + +``` +xdebug.max_nesting_level=500 +``` + + +**Cannot autowire service "_[some input type]_": argument "$..." of method "..." is type-hinted "...", you should configure its value explicitly.** + +The message says that Symfony is trying to instantiate an input type as a service. This can happen if you put your +GraphQLite controllers in the Symfony controller namespace (`App\Controller` by default). Symfony will assume that any +object type-hinted in a method of a controller is a service ([because all controllers are tagged with the "controller.service_arguments" tag](https://symfony.com/doc/current/service_container/3.3-di-changes.html#controllers-are-registered-as-services)) + +To fix this issue, do not put your GraphQLite controller in the same namespace as the Symfony controllers and +reconfigure your `config/graphqlite.yml` file to point to your new namespace. diff --git a/website/versioned_docs/version-7.0.0/type-mapping.mdx b/website/versioned_docs/version-7.0.0/type-mapping.mdx new file mode 100644 index 0000000000..2051dd9559 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/type-mapping.mdx @@ -0,0 +1,664 @@ +--- +id: type-mapping +title: Type mapping +sidebar_label: Type mapping +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +As explained in the [queries](queries.mdx) section, the job of GraphQLite is to create GraphQL types from PHP types. + +## Scalar mapping + +Scalar PHP types can be type-hinted to the corresponding GraphQL types: + +* `string` +* `int` +* `bool` +* `float` + +For instance: + + + + +```php +namespace App\Controller; + +use TheCodingMachine\GraphQLite\Annotations\Query; + +class MyController +{ + #[Query] + public function hello(string $name): string + { + return 'Hello ' . $name; + } +} +``` + + + + +```php +namespace App\Controller; + +use TheCodingMachine\GraphQLite\Annotations\Query; + +class MyController +{ + /** + * @Query + */ + public function hello(string $name): string + { + return 'Hello ' . $name; + } +} +``` + + + + +## Class mapping + +When returning a PHP class in a query, you must annotate this class using `@Type` and `@Field` annotations: + + + + +```php +namespace App\Entities; + +use TheCodingMachine\GraphQLite\Annotations\Field; +use TheCodingMachine\GraphQLite\Annotations\Type; + +#[Type] +class Product +{ + // ... + + #[Field] + public function getName(): string + { + return $this->name; + } + + #[Field] + public function getPrice(): ?float + { + return $this->price; + } +} +``` + + + + +```php +namespace App\Entities; + +use TheCodingMachine\GraphQLite\Annotations\Field; +use TheCodingMachine\GraphQLite\Annotations\Type; + +/** + * @Type() + */ +class Product +{ + // ... + + /** + * @Field() + */ + public function getName(): string + { + return $this->name; + } + + /** + * @Field() + */ + public function getPrice(): ?float + { + return $this->price; + } +} +``` + + + + +**Note:** The GraphQL output type name generated by GraphQLite is equal to the class name of the PHP class. So if your +PHP class is `App\Entities\Product`, then the GraphQL type will be named "Product". + +In case you have several types with the same class name in different namespaces, you will face a naming collision. +Hopefully, you can force the name of the GraphQL output type using the "name" attribute: + + + + +```php +#[Type(name: "MyProduct")] +class Product { /* ... */ } +``` + + + + +```php +/** + * @Type(name="MyProduct") + */ +class Product { /* ... */ } +``` + + + + + + +## Array mapping + +You can type-hint against arrays (or iterators) as long as you add a detailed `@return` statement in the PHPDoc. + + + + +```php +/** + * @return User[] <=== we specify that the array is an array of User objects. + */ +#[Query] +public function users(int $limit, int $offset): array +{ + // Some code that returns an array of "users". +} +``` + + + + +```php +/** + * @Query + * @return User[] <=== we specify that the array is an array of User objects. + */ +public function users(int $limit, int $offset): array +{ + // Some code that returns an array of "users". +} +``` + + + + +## ID mapping + +GraphQL comes with a native `ID` type. PHP has no such type. + +There are two ways with GraphQLite to handle such type. + +### Force the outputType + + + + +```php +#[Field(outputType: "ID")] +public function getId(): string +{ + // ... +} +``` + + + + +```php +/** + * @Field(outputType="ID") + */ +public function getId(): string +{ + // ... +} +``` + + + + +Using the `outputType` attribute of the `@Field` annotation, you can force the output type to `ID`. + +You can learn more about forcing output types in the [custom types section](custom-types.mdx). + +### ID class + + + + +```php +use TheCodingMachine\GraphQLite\Types\ID; + +#[Field] +public function getId(): ID +{ + // ... +} +``` + + + + +```php +use TheCodingMachine\GraphQLite\Types\ID; + +/** + * @Field + */ +public function getId(): ID +{ + // ... +} +``` + + + + +Note that you can also use the `ID` class as an input type: + + + + +```php +use TheCodingMachine\GraphQLite\Types\ID; + +#[Mutation] +public function save(ID $id, string $name): Product +{ + // ... +} +``` + + + + +```php +use TheCodingMachine\GraphQLite\Types\ID; + +/** + * @Mutation + */ +public function save(ID $id, string $name): Product +{ + // ... +} +``` + + + + +## Date mapping + +Out of the box, GraphQL does not have a `DateTime` type, but we took the liberty to add one, with sensible defaults. + +When used as an output type, `DateTimeImmutable` or `DateTimeInterface` PHP classes are +automatically mapped to this `DateTime` GraphQL type. + + + + +```php +#[Field] +public function getDate(): \DateTimeInterface +{ + return $this->date; +} +``` + + + + +```php +/** + * @Field + */ +public function getDate(): \DateTimeInterface +{ + return $this->date; +} +``` + + + + +The `date` field will be of type `DateTime`. In the returned JSON response to a query, the date is formatted as a string +in the **ISO8601** format (aka ATOM format). + +
+ PHP DateTime type is not supported. +
+ +## Union types + +Union types for return are supported in GraphQLite as of version 6.0: + + + + +```php +#[Query] +public function companyOrContact(int $id): Company|Contact +{ + // Some code that returns a company or a contact. +} +``` + + + + +```php +/** + * @Query + * @return Company|Contact + */ +public function companyOrContact(int $id) +{ + // Some code that returns a company or a contact. +} +``` + + + + +## Enum types + +PHP 8.1 introduced native support for Enums. GraphQLite now also supports native enums as of version 5.1. + +```php +#[Type] +enum Status: string +{ + case ON = 'on'; + case OFF = 'off'; + case PENDING = 'pending'; +} +``` + +```php +/** + * @return User[] + */ +#[Query] +public function users(Status $status): array +{ + if ($status === Status::ON) { + // Your logic + } + // ... +} +``` + +```graphql +query users($status: Status!) {} + users(status: $status) { + id + } +} +``` + +By default, the name of the GraphQL enum type will be the name of the class. If you have a naming conflict (two classes +that live in different namespaces with the same class name), you can solve it using the `name` property on the `@Type` annotation: + +```php +namespace Model\User; + +#[Type(name: "UserStatus")] +enum Status: string +{ + // ... +} +``` + + +### Enum types with myclabs/php-enum + +
+ This implementation is now deprecated and will be removed in the future. You are advised to use native enums instead. +
+ +*Prior to version 5.1, GraphQLite only supported Enums through the 3rd party library, [myclabs/php-enum](https://github.com/myclabs/php-enum). If you'd like to use this implementation you'll first need to add this library as a dependency to your application.* + +```bash +$ composer require myclabs/php-enum +``` + +Now, any class extending the `MyCLabs\Enum\Enum` class will be mapped to a GraphQL enum: + + + + +```php +use MyCLabs\Enum\Enum; + +class StatusEnum extends Enum +{ + private const ON = 'on'; + private const OFF = 'off'; + private const PENDING = 'pending'; +} +``` + +```php +/** + * @return User[] + */ +#[Query] +public function users(StatusEnum $status): array +{ + if ($status == StatusEnum::ON()) { + // Note that the "magic" ON() method returns an instance of the StatusEnum class. + // Also, note that we are comparing this instance using "==" (using "===" would fail as we have 2 different instances here) + // ... + } + // ... +} +``` + + + + +```php +use MyCLabs\Enum\Enum; + +class StatusEnum extends Enum +{ + private const ON = 'on'; + private const OFF = 'off'; + private const PENDING = 'pending'; +} +``` + +```php +/** + * @Query + * @return User[] + */ +public function users(StatusEnum $status): array +{ + if ($status == StatusEnum::ON()) { + // Note that the "magic" ON() method returns an instance of the StatusEnum class. + // Also, note that we are comparing this instance using "==" (using "===" would fail as we have 2 different instances here) + // ... + } + // ... +} +``` + + + + +```graphql +query users($status: StatusEnum!) {} + users(status: $status) { + id + } +} +``` + +By default, the name of the GraphQL enum type will be the name of the class. If you have a naming conflict (two classes +that live in different namespaces with the same class name), you can solve it using the `@EnumType` annotation: + + + + +```php +use TheCodingMachine\GraphQLite\Annotations\EnumType; + +#[EnumType(name: "UserStatus")] +class StatusEnum extends Enum +{ + // ... +} +``` + + + + +```php +use TheCodingMachine\GraphQLite\Annotations\EnumType; + +/** + * @EnumType(name="UserStatus") + */ +class StatusEnum extends Enum +{ + // ... +} +``` + + + + +
GraphQLite must be able to find all the classes extending the "MyCLabs\Enum" class +in your project. By default, GraphQLite will look for "Enum" classes in the namespaces declared for the types. For this +reason, your enum classes MUST be in one of the namespaces declared for the types in your GraphQLite +configuration file.
+ +## Deprecation of fields + +You can mark a field as deprecated in your GraphQL Schema by just annotating it with the `@deprecated` PHPDoc annotation. Note that a description (reason) is required for the annotation to be rendered. + +```php +namespace App\Entities; + +use TheCodingMachine\GraphQLite\Annotations\Field; +use TheCodingMachine\GraphQLite\Annotations\Type; + +/** + * @Type() + */ +class Product +{ + // ... + + /** + * @Field() + */ + public function getName(): string + { + return $this->name; + } + + /** + * @Field() + * @deprecated use field `name` instead + */ + public function getProductName(): string + { + return $this->name; + } +} +``` + +This will add the `@deprecated` directive to the field in the GraphQL Schema which sets the `isDeprecated` field to `true` and adds the reason to the `deprecationReason` field in an introspection query. Fields marked as deprecated can still be queried, but will be returned in an introspection query only if `includeDeprecated` is set to `true`. + +```graphql +query { + __type(name: "Product") { + fields(includeDeprecated: true) { + name + isDeprecated + deprecationReason + } + } +} +``` + +## More scalar types + +Available in GraphQLite 4.0+ + +GraphQL supports "custom" scalar types. GraphQLite supports adding more GraphQL scalar types. + +If you need more types, you can check the [GraphQLite Misc. Types library](https://github.com/thecodingmachine/graphqlite-misc-types). +It adds support for more scalar types out of the box in GraphQLite. + +Or if you have some special needs, [you can develop your own scalar types](custom-types#registering-a-custom-scalar-type-advanced). diff --git a/website/versioned_docs/version-7.0.0/universal-service-providers.md b/website/versioned_docs/version-7.0.0/universal-service-providers.md new file mode 100644 index 0000000000..8375b0e1e4 --- /dev/null +++ b/website/versioned_docs/version-7.0.0/universal-service-providers.md @@ -0,0 +1,74 @@ +--- +id: universal-service-providers +title: "Getting started with a framework compatible with container-interop/service-provider" +sidebar_label: Universal service providers +--- + +[container-interop/service-provider](https://github.com/container-interop/service-provider/) is an experimental project +aiming to bring interoperability between framework module systems. + +If your framework is compatible with [container-interop/service-provider](https://github.com/container-interop/service-provider/), +GraphQLite comes with a service provider that you can leverage. + +## Installation + +Open a terminal in your current project directory and run: + +```console +$ composer require thecodingmachine/graphqlite-universal-service-provider +``` + +## Requirements + +In order to bootstrap GraphQLite, you will need: + +- A PSR-16 cache + +Additionally, you will have to route the HTTP requests to the underlying GraphQL library. + +GraphQLite relies on the [webonyx/graphql-php](http://webonyx.github.io/graphql-php/) library internally. +This library plays well with PSR-7 requests and we provide a [PSR-15 middleware](other-frameworks.mdx). + +## Integration + +Webonyx/graphql-php library requires a [Schema](https://webonyx.github.io/graphql-php/type-system/schema/) in order to resolve +GraphQL queries. The service provider provides this `Schema` class. + +[Checkout the the service-provider documentation](https://github.com/thecodingmachine/graphqlite-universal-service-provider) + +## Sample usage + +```json title="composer.json" +{ + "require": { + "mnapoli/simplex": "^0.5", + "thecodingmachine/graphqlite-universal-service-provider": "^3", + "thecodingmachine/symfony-cache-universal-module": "^1" + }, + "minimum-stability": "dev", + "prefer-stable": true +} +``` + +```php title="index.php" +set('graphqlite.namespace.types', ['App\\Types']); +$container->set('graphqlite.namespace.controllers', ['App\\Controllers']); + +$schema = $container->get(Schema::class); + +// or if you want the PSR-15 middleware: + +$middleware = $container->get(Psr15GraphQLMiddlewareBuilder::class); +``` diff --git a/website/versioned_docs/version-7.0.0/validation.mdx b/website/versioned_docs/version-7.0.0/validation.mdx new file mode 100644 index 0000000000..dfb2c925dd --- /dev/null +++ b/website/versioned_docs/version-7.0.0/validation.mdx @@ -0,0 +1,287 @@ +--- +id: validation +title: Validation +sidebar_label: User input validation +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +GraphQLite does not handle user input validation by itself. It is out of its scope. + +However, it can integrate with your favorite framework validation mechanism. The way you validate user input will +therefore depend on the framework you are using. + +## Validating user input with Laravel + +If you are using Laravel, jump directly to the [GraphQLite Laravel package advanced documentation](laravel-package-advanced.mdx#support-for-laravel-validation-rules) +to learn how to use the Laravel validation with GraphQLite. + +## Validating user input with Symfony validator + +GraphQLite provides a bridge to use the [Symfony validator](https://symfony.com/doc/current/validation.html) directly in your application. + +- If you are using Symfony and the Symfony GraphQLite bundle, the bridge is available out of the box +- If you are using another framework, the "Symfony validator" component can be used in standalone mode. If you want to + add it to your project, you can require the *thecodingmachine/graphqlite-symfony-validator-bridge* package: + + ```bash + $ composer require thecodingmachine/graphqlite-symfony-validator-bridge + ``` + +### Using the Symfony validator bridge + +Usually, when you use the Symfony validator component, you put annotations in your entities and you validate those entities +using the `Validator` object. + + + + +```php title="UserController.php" +use Symfony\Component\Validator\Validator\ValidatorInterface; +use TheCodingMachine\GraphQLite\Validator\ValidationFailedException + +class UserController +{ + private $validator; + + public function __construct(ValidatorInterface $validator) + { + $this->validator = $validator; + } + + #[Mutation] + public function createUser(string $email, string $password): User + { + $user = new User($email, $password); + + // Let's validate the user + $errors = $this->validator->validate($user); + + // Throw an appropriate GraphQL exception if validation errors are encountered + ValidationFailedException::throwException($errors); + + // No errors? Let's continue and save the user + // ... + } +} +``` + + + + +```php title="UserController.php" +use Symfony\Component\Validator\Validator\ValidatorInterface; +use TheCodingMachine\GraphQLite\Validator\ValidationFailedException + +class UserController +{ + private $validator; + + public function __construct(ValidatorInterface $validator) + { + $this->validator = $validator; + } + + /** + * @Mutation + */ + public function createUser(string $email, string $password): User + { + $user = new User($email, $password); + + // Let's validate the user + $errors = $this->validator->validate($user); + + // Throw an appropriate GraphQL exception if validation errors are encountered + ValidationFailedException::throwException($errors); + + // No errors? Let's continue and save the user + // ... + } +} +``` + + + + +Validation rules are added directly to the object in the domain model: + + + + +```php title="User.php" +use Symfony\Component\Validator\Constraints as Assert; + +class User +{ + #[Assert\Email(message: "The email '{{ value }}' is not a valid email.", checkMX: true)] + private $email; + + /** + * The NotCompromisedPassword assertion asks the "HaveIBeenPawned" service if your password has already leaked or not. + */ + #[Assert\NotCompromisedPassword] + private $password; + + public function __construct(string $email, string $password) + { + $this->email = $email; + $this->password = $password; + } + + // ... +} +``` + + + + +```php title="User.php" +use Symfony\Component\Validator\Constraints as Assert; + +class User +{ + /** + * @Assert\Email( + * message = "The email '{{ value }}' is not a valid email.", + * checkMX = true + * ) + */ + private $email; + + /** + * The NotCompromisedPassword assertion asks the "HaveIBeenPawned" service if your password has already leaked or not. + * @Assert\NotCompromisedPassword + */ + private $password; + + public function __construct(string $email, string $password) + { + $this->email = $email; + $this->password = $password; + } + + // ... +} +``` + + + + +If a validation fails, GraphQLite will return the failed validations in the "errors" section of the JSON response: + +```json +{ + "errors": [ + { + "message": "The email '\"foo@thisdomaindoesnotexistatall.com\"' is not a valid email.", + "extensions": { + "code": "bf447c1c-0266-4e10-9c6c-573df282e413", + "field": "email", + "category": "Validate" + } + } + ] +} +``` + + +### Using the validator directly on a query / mutation / subscription / factory ... + +If the data entered by the user is mapped to an object, please use the "validator" instance directly as explained in +the last chapter. It is a best practice to put your validation layer as close as possible to your domain model. + +If the data entered by the user is **not** mapped to an object, you can directly annotate your query, mutation, factory... + +
+ You generally don't want to do this. It is a best practice to put your validation constraints +on your domain objects. Only use this technique if you want to validate user input and user input will not be stored +in a domain object. +
+ +Use the `@Assertion` annotation to validate directly the user input. + +```php +use Symfony\Component\Validator\Constraints as Assert; +use TheCodingMachine\GraphQLite\Validator\Annotations\Assertion; + +/** + * @Query + * @Assertion(for="email", constraint=@Assert\Email()) + */ +public function findByMail(string $email): User +{ + // ... +} +``` + +Notice that the "constraint" parameter contains an annotation (it is an annotation wrapped in an annotation). + +You can also pass an array to the `constraint` parameter: + +```php +@Assertion(for="email", constraint={@Assert\NotBlank(), @Assert\Email()}) +``` + +
Heads up! The "@Assertion" annotation is only available as a Doctrine annotations. You cannot use it as a PHP 8 attributes
+ +## Custom InputType Validation + +GraphQLite also supports a fully custom validation implementation for all input types defined with an `@Input` annotation or PHP8 `#[Input]` attribute. This offers a way to validate input types before they're available as a method parameter of your query and mutation controllers. This way, when you're using your query or mutation controllers, you can feel confident that your input type objects have already been validated. + +
+

It's important to note that this validation implementation does not validate input types created with a factory. If you are creating an input type with a factory, or using primitive parameters in your query/mutation controllers, you should be sure to validate these independently. This is strictly for input type objects.

+ +

You can use one of the framework validation libraries listed above or implement your own validation for these cases. If you're using input type objects for most all of your query and mutation controllers, then there is little additional validation concerns with regards to user input. There are many reasons why you should consider defaulting to an InputType object, as opposed to individual arguments, for your queries and mutations. This is just one additional perk.

+
+ +To get started with validation on input types defined by an `@Input` annotation, you'll first need to register your validator with the `SchemaFactory`. + +```php +$factory = new SchemaFactory($cache, $this->container); +$factory->addControllerNamespace('App\\Controllers'); +$factory->addTypeNamespace('App'); +// Register your validator +$factory->setInputTypeValidator($this->container->get('your_validator')); +$factory->createSchema(); +``` + +Your input type validator must implement the `TheCodingMachine\GraphQLite\Types\InputTypeValidatorInterface`, as shown below: + +```php +interface InputTypeValidatorInterface +{ + /** + * Checks to see if the Validator is currently enabled. + */ + public function isEnabled(): bool; + + /** + * Performs the validation of the InputType. + * + * @param object $input The input type object to validate + */ + public function validate(object $input): void; +} +``` + +The interface is quite simple. Handle all of your own validation logic in the `validate` method. For example, you might use Symfony's annotation based validation in addition to some other custom validation logic. It's really up to you on how you wish to handle your own validation. The `validate` method will receive the input type object populated with the user input. + +You'll notice that the `validate` method has a `void` return. The purpose here is to encourage you to throw an Exception or handle validation output however you best see fit. GraphQLite does it's best to stay out of your way and doesn't make attempts to handle validation output. You can, however, throw an instance of `TheCodingMachine\GraphQLite\Exceptions\GraphQLException` or `TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException` as usual (see [Error Handling](error-handling) for more details). + +Also available is the `isEnabled` method. This method is checked before executing validation on an InputType being resolved. You can work out your own logic to selectively enable or disable validation through this method. In most cases, you can simply return `true` to keep it always enabled. + +And that's it, now, anytime an input type is resolved, the validator will be executed on that input type immediately after it has been hydrated with user input. diff --git a/website/versioned_sidebars/version-7.0.0-sidebars.json b/website/versioned_sidebars/version-7.0.0-sidebars.json new file mode 100644 index 0000000000..571f243dba --- /dev/null +++ b/website/versioned_sidebars/version-7.0.0-sidebars.json @@ -0,0 +1,58 @@ +{ + "docs": { + "Introduction": [ + "index" + ], + "Installation": [ + "getting-started", + "symfony-bundle", + "laravel-package", + "universal-service-providers", + "other-frameworks" + ], + "Usage": [ + "queries", + "mutations", + "subscriptions", + "type-mapping", + "autowiring", + "extend-type", + "external-type-declaration", + "input-types", + "inheritance-interfaces", + "error-handling", + "validation" + ], + "Security": [ + "authentication-authorization", + "fine-grained-security", + "implementing-security", + "operation-complexity" + ], + "Performance": [ + "query-plan", + "prefetch-method", + "automatic-persisted-queries" + ], + "Advanced": [ + "file-uploads", + "pagination", + "custom-types", + "field-middlewares", + "argument-resolving", + "extend-input-type", + "multiple-output-types", + "symfony-bundle-advanced", + "laravel-package-advanced", + "internals", + "troubleshooting", + "migrating" + ], + "Reference": [ + "doctrine-annotations-attributes", + "annotations-reference", + "semver", + "changelog" + ] + } +} diff --git a/website/versions.json b/website/versions.json index 6d2b2717b5..c6985e8cd8 100644 --- a/website/versions.json +++ b/website/versions.json @@ -1,4 +1,5 @@ [ + "7.0.0", "6.1", "6.0", "5.0", From 2b25735484c7c0f5bda1d0883df2b31354f9e01e Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Wed, 20 Mar 2024 02:01:07 -0400 Subject: [PATCH 060/108] Rollback to v3 for actions/upload-artifact (#666) --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 9a617c5f7d..47b6c5a0fd 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -78,7 +78,7 @@ jobs: run: "composer cs-check" - name: "Archive code coverage results" - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v3 with: name: "codeCoverage" path: "build" From 6ed178f85ebd6cb8f52810b20114673c84e97e00 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Wed, 20 Mar 2024 21:50:41 -0400 Subject: [PATCH 061/108] Update README.md Subscriptions are now supported. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 46df4f459a..a711be4657 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ A GraphQL library for PHP that allows you to use attributes (or annotations) to * Create a complete GraphQL API by simply annotating your PHP classes * Framework agnostic, but with [Symfony](https://github.com/thecodingmachine/graphqlite-bundle) and [Laravel](https://github.com/thecodingmachine/graphqlite-laravel) integrations available! -* Comes with batteries included :battery:: queries, mutations, mapping of arrays/iterators, file uploads, extendable types and more! +* Comes with batteries included :battery:: queries, mutations, subscriptions, mapping of arrays/iterators, file uploads, extendable types and more! ## Basic example From 8ae940ea2bc97a96ef79c8f2981272d68681ba83 Mon Sep 17 00:00:00 2001 From: Shish Date: Sat, 23 Mar 2024 01:17:48 +0000 Subject: [PATCH 062/108] Add a comment mentioning that FilesystemCache can be used when APCu isn't available (#662) APCu isn't enabled by default, which makes the example crash, and there are no hints as to what the user is supposed to do about it --- website/docs/other-frameworks.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/website/docs/other-frameworks.mdx b/website/docs/other-frameworks.mdx index 64806bd84a..5d74b0c3a7 100644 --- a/website/docs/other-frameworks.mdx +++ b/website/docs/other-frameworks.mdx @@ -261,6 +261,10 @@ return new Picotainer([ return $builder->createMiddleware(); }, CacheInterface::class => function() { + // Any PSR-16 cache should work - APCu is recommended for good + // performance, but it requires that module to be enabled. For + // small scale testing with zero dependencies, FilesystemCache + // can be used instead. return new ApcuCache(); }, Schema::class => function(ContainerInterface $container) { From 23d6c9d7554a07842f8904d96c080e30bc0e683a Mon Sep 17 00:00:00 2001 From: Oleksandr Prypkhan Date: Wed, 27 Mar 2024 04:37:38 +0200 Subject: [PATCH 063/108] Use ComposerFinder's useAutoloading method (#667) * Use ComposerFinder's useAutoloading method * Drop useAutoloading() method * Add "Disabling autoloading" section to the docs * Fix code style * Change docs wording --- composer.json | 2 +- src/GlobControllerQueryProvider.php | 5 +---- src/SchemaFactory.php | 6 ++++-- src/Utils/Namespaces/NS.php | 2 +- src/Utils/Namespaces/NamespaceFactory.php | 11 +++++----- tests/AbstractQueryProviderTest.php | 3 ++- .../BadNamespace/BadlyNamespacedClass.php | 8 -------- .../BadNamespace/ClassWithoutNamespace.php | 5 ----- .../Integration/Models/BadNamespaceClass.php | 9 --------- tests/Integration/IntegrationTestCase.php | 10 +++++++++- website/docs/other-frameworks.mdx | 20 +++++++++++++++++++ 11 files changed, 43 insertions(+), 38 deletions(-) delete mode 100644 tests/Fixtures/BadNamespace/BadlyNamespacedClass.php delete mode 100644 tests/Fixtures/BadNamespace/ClassWithoutNamespace.php delete mode 100644 tests/Fixtures/Integration/Models/BadNamespaceClass.php diff --git a/composer.json b/composer.json index d3e79e0426..eea76d30ac 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "symfony/expression-language": "^4 || ^5 || ^6 || ^7", "thecodingmachine/cache-utils": "^1", "webonyx/graphql-php": "^v15.0", - "kcs/class-finder": "^0.4.0" + "kcs/class-finder": "^0.5.0" }, "require-dev": { "beberlei/porpaginas": "^1.2 || ^2.0", diff --git a/src/GlobControllerQueryProvider.php b/src/GlobControllerQueryProvider.php index 6fc7e659d1..f7ed17170e 100644 --- a/src/GlobControllerQueryProvider.php +++ b/src/GlobControllerQueryProvider.php @@ -6,7 +6,6 @@ use GraphQL\Type\Definition\FieldDefinition; use InvalidArgumentException; -use Kcs\ClassFinder\Finder\ComposerFinder; use Kcs\ClassFinder\Finder\FinderInterface; use Psr\Container\ContainerInterface; use Psr\SimpleCache\CacheInterface; @@ -33,7 +32,6 @@ final class GlobControllerQueryProvider implements QueryProviderInterface { /** @var array|null */ private array|null $instancesList = null; - private FinderInterface $finder; private AggregateControllerQueryProvider|null $aggregateControllerQueryProvider = null; private CacheContractInterface $cacheContract; @@ -47,11 +45,10 @@ public function __construct( private readonly ContainerInterface $container, private readonly AnnotationReader $annotationReader, private readonly CacheInterface $cache, - FinderInterface|null $finder = null, + private readonly FinderInterface $finder, int|null $cacheTtl = null, ) { - $this->finder = $finder ?? new ComposerFinder(); $this->cacheContract = new Psr16Adapter( $this->cache, str_replace(['\\', '{', '}', '(', ')', '/', '@', ':'], '_', $namespace), diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index e49f51e8a1..04e4bfa6ba 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -8,6 +8,7 @@ use Doctrine\Common\Annotations\PsrCachedReader; use Doctrine\Common\Annotations\Reader; use GraphQL\Type\SchemaConfig; +use Kcs\ClassFinder\Finder\ComposerFinder; use Kcs\ClassFinder\Finder\FinderInterface; use MyCLabs\Enum\Enum; use PackageVersions\Versions; @@ -343,8 +344,9 @@ public function createSchema(): Schema $cachedDocBlockFactory = new CachedDocBlockFactory($namespacedCache); $namingStrategy = $this->namingStrategy ?: new NamingStrategy(); $typeRegistry = new TypeRegistry(); + $finder = $this->finder ?? new ComposerFinder(); - $namespaceFactory = new NamespaceFactory($namespacedCache, $this->finder, $this->globTTL); + $namespaceFactory = new NamespaceFactory($namespacedCache, $finder, $this->globTTL); $nsList = array_map( static fn (string $namespace) => $namespaceFactory->createNamespace($namespace), $this->typeNamespaces, @@ -493,7 +495,7 @@ public function createSchema(): Schema $this->container, $annotationReader, $namespacedCache, - $this->finder, + $finder, $this->globTTL, ); } diff --git a/src/Utils/Namespaces/NS.php b/src/Utils/Namespaces/NS.php index 262a42494b..f1c51f3a18 100644 --- a/src/Utils/Namespaces/NS.php +++ b/src/Utils/Namespaces/NS.php @@ -79,7 +79,7 @@ public function getClassList(): array $this->classes = []; /** @var class-string $className */ /** @var ReflectionClass $reflector */ - foreach ($this->finder->inNamespace($this->namespace) as $className => $reflector) { + foreach ((clone $this->finder)->inNamespace($this->namespace) as $className => $reflector) { if (! ($reflector instanceof ReflectionClass)) { continue; } diff --git a/src/Utils/Namespaces/NamespaceFactory.php b/src/Utils/Namespaces/NamespaceFactory.php index e4d462cc2a..719c80bb22 100644 --- a/src/Utils/Namespaces/NamespaceFactory.php +++ b/src/Utils/Namespaces/NamespaceFactory.php @@ -4,7 +4,6 @@ namespace TheCodingMachine\GraphQLite\Utils\Namespaces; -use Kcs\ClassFinder\Finder\ComposerFinder; use Kcs\ClassFinder\Finder\FinderInterface; use Psr\SimpleCache\CacheInterface; @@ -15,11 +14,11 @@ */ final class NamespaceFactory { - private FinderInterface $finder; - - public function __construct(private readonly CacheInterface $cache, FinderInterface|null $finder = null, private int|null $globTTL = 2) - { - $this->finder = $finder ?? new ComposerFinder(); + public function __construct( + private readonly CacheInterface $cache, + private readonly FinderInterface $finder, + private int|null $globTTL = 2, + ) { } /** @param string $namespace A PHP namespace */ diff --git a/tests/AbstractQueryProviderTest.php b/tests/AbstractQueryProviderTest.php index 16b6de62d5..04c98fd4c8 100644 --- a/tests/AbstractQueryProviderTest.php +++ b/tests/AbstractQueryProviderTest.php @@ -9,6 +9,7 @@ use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\OutputType; use GraphQL\Type\Definition\Type; +use Kcs\ClassFinder\Finder\ComposerFinder; use phpDocumentor\Reflection\TypeResolver as PhpDocumentorTypeResolver; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; @@ -466,7 +467,7 @@ protected function getNamespaceFactory(): NamespaceFactory $arrayAdapter->setLogger(new ExceptionLogger()); $psr16Cache = new Psr16Cache($arrayAdapter); - $this->namespaceFactory = new NamespaceFactory($psr16Cache); + $this->namespaceFactory = new NamespaceFactory($psr16Cache, new ComposerFinder()); } return $this->namespaceFactory; } diff --git a/tests/Fixtures/BadNamespace/BadlyNamespacedClass.php b/tests/Fixtures/BadNamespace/BadlyNamespacedClass.php deleted file mode 100644 index 258dac6fd5..0000000000 --- a/tests/Fixtures/BadNamespace/BadlyNamespacedClass.php +++ /dev/null @@ -1,8 +0,0 @@ - static function (ContainerInterface $container) { return new Schema($container->get(QueryProviderInterface::class), $container->get(RecursiveTypeMapperInterface::class), $container->get(TypeResolver::class), $container->get(RootTypeMapperInterface::class)); }, + FinderInterface::class => fn () => new ComposerFinder(), QueryProviderInterface::class => static function (ContainerInterface $container) { $queryProvider = new GlobControllerQueryProvider( 'TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers', @@ -94,6 +97,7 @@ public function createContainer(array $overloadedServices = []): ContainerInterf $container->get(BasicAutoWiringContainer::class), $container->get(AnnotationReader::class), new Psr16Cache(new ArrayAdapter()), + $container->get(FinderInterface::class), ); $queryProvider = new AggregateQueryProvider([ @@ -104,6 +108,7 @@ public function createContainer(array $overloadedServices = []): ContainerInterf $container->get(BasicAutoWiringContainer::class), $container->get(AnnotationReader::class), new Psr16Cache(new ArrayAdapter()), + $container->get(FinderInterface::class), ), ]); @@ -201,7 +206,10 @@ public function createContainer(array $overloadedServices = []): ContainerInterf NamespaceFactory::class => static function (ContainerInterface $container) { $arrayAdapter = new ArrayAdapter(); $arrayAdapter->setLogger(new ExceptionLogger()); - return new NamespaceFactory(new Psr16Cache($arrayAdapter)); + return new NamespaceFactory( + new Psr16Cache($arrayAdapter), + $container->get(FinderInterface::class), + ); }, GlobTypeMapper::class => static function (ContainerInterface $container) { $arrayAdapter = new ArrayAdapter(); diff --git a/website/docs/other-frameworks.mdx b/website/docs/other-frameworks.mdx index 5d74b0c3a7..9402451116 100644 --- a/website/docs/other-frameworks.mdx +++ b/website/docs/other-frameworks.mdx @@ -98,6 +98,26 @@ use TheCodingMachine\GraphQLite\Context\Context; $result = GraphQL::executeQuery($schema, $query, null, new Context(), $variableValues); ``` +### Disabling autoloading + +GraphQLite uses `kcs/class-finder` to find all classes that have GraphQLite attributes. By default, it uses +autoloading under the hood. But if you have an older codebase that contains classes with incorrect or missing +namespaces, you may need to use `include_once` instead. To do so, you can overwrite the finder using `setFinder()`: + +```php +use Kcs\ClassFinder\Finder\ComposerFinder; +use TheCodingMachine\GraphQLite\SchemaFactory; + +$factory = new SchemaFactory($cache, $container); +$factory->addControllerNamespace('App\\Controllers\\') + ->addTypeNamespace('App\\') + ->setFinder( + (new ComposerFinder())->useAutoloading(false) + ); + +$schema = $factory->createSchema(); +``` + ## Minimal example The smallest working example using no framework is: From 59048a10310d0e9bbc46efe2f19ab64297dfa530 Mon Sep 17 00:00:00 2001 From: Yurii <141632421+fogrye@users.noreply.github.com> Date: Mon, 1 Apr 2024 02:11:41 +0200 Subject: [PATCH 064/108] test: update phpunit (#674) Fixes deprecations, AbstractQueryProviderTest renamed because Test suffix produces warning in file with no tests --- .gitignore | 1 + composer.json | 2 +- phpunit.xml.dist | 35 +++++++------------ ...iderTest.php => AbstractQueryProvider.php} | 4 +-- .../AggregateControllerQueryProviderTest.php | 2 +- .../BasicAutoWiringContainerTest.php | 4 +-- tests/Containers/LazyContainerTest.php | 4 +-- tests/FactoryContextTest.php | 2 +- tests/FieldsBuilderTest.php | 2 +- tests/GlobControllerQueryProviderTest.php | 2 +- tests/InputTypeUtilsTest.php | 2 +- tests/Integration/QueryComplexityTest.php | 9 ++--- tests/Mappers/CannotMapTypeTraitTest.php | 4 +-- tests/Mappers/CompositeTypeMapperTest.php | 4 +-- tests/Mappers/GlobTypeMapperTest.php | 4 +-- .../ContainerParameterMapperTest.php | 4 +-- .../InjectUserParameterHandlerTest.php | 11 +++--- .../PrefetchParameterMiddlewareTest.php | 4 +-- tests/Mappers/Parameters/TypeMapperTest.php | 4 +-- tests/Mappers/PorpaginasTypeMapperTest.php | 4 +-- tests/Mappers/RecursiveTypeMapperTest.php | 4 +-- tests/Mappers/Root/BaseTypeMapperTest.php | 14 ++++---- tests/Mappers/Root/CompoundTypeMapperTest.php | 4 +-- tests/Mappers/Root/IteratorTypeMapperTest.php | 12 +++---- .../Root/MyCLabsEnumTypeMapperTest.php | 4 +-- .../Root/NullableTypeMapperAdapterTest.php | 21 ++++++----- .../Mappers/StaticClassListTypeMapperTest.php | 4 +-- tests/Mappers/StaticTypeMapperTest.php | 4 +-- .../AuthorizationFieldMiddlewareTest.php | 4 +-- .../AuthorizationInputFieldMiddlewareTest.php | 4 +-- tests/Middlewares/CostFieldMiddlewareTest.php | 11 +++--- tests/Parameters/InjectUserParameterTest.php | 7 ++-- tests/QueryFieldDescriptorTest.php | 5 ++- tests/RootTypeMapperFactoryContextTest.php | 2 +- tests/SchemaTest.php | 2 +- tests/TypeGeneratorTest.php | 2 +- tests/Types/ArgumentResolverTest.php | 11 ++++-- tests/Types/InputTypeTest.php | 9 +++-- .../ResolvableMutableInputObjectTypeTest.php | 4 +-- .../Types/TypeAnnotatedInterfaceTypeTest.php | 4 +-- tests/Types/UnionTypeTest.php | 4 +-- tests/Utils/NsTest.php | 3 +- tests/Utils/PropertyAccessorTest.php | 17 +++------ 43 files changed, 121 insertions(+), 143 deletions(-) rename tests/{AbstractQueryProviderTest.php => AbstractQueryProvider.php} (99%) diff --git a/.gitignore b/.gitignore index 5c515c507c..e35760b84d 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ website/.cache-loader .phpunit.result.cache .phpstan-cache +.phpunit.cache diff --git a/composer.json b/composer.json index eea76d30ac..e482e16693 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,7 @@ "php-coveralls/php-coveralls": "^2.1", "phpstan/extension-installer": "^1.1", "phpstan/phpstan": "^1.9", - "phpunit/phpunit": "^8.5.19 || ^9.5.8", + "phpunit/phpunit": "^10.1 || ^11.0", "symfony/var-dumper": "^5.4 || ^6.0 || ^7", "thecodingmachine/phpstan-strict-rules": "^1.0" }, diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 489d69899f..7c3964cbe5 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,32 +1,21 @@ - - + + + + + + + ./tests/ ./tests/Bootstrap.php - - - + + + src/ - - - - - - + + diff --git a/tests/AbstractQueryProviderTest.php b/tests/AbstractQueryProvider.php similarity index 99% rename from tests/AbstractQueryProviderTest.php rename to tests/AbstractQueryProvider.php index 04c98fd4c8..37ca186cce 100644 --- a/tests/AbstractQueryProviderTest.php +++ b/tests/AbstractQueryProvider.php @@ -57,7 +57,7 @@ use TheCodingMachine\GraphQLite\Utils\Namespaces\NamespaceFactory; use UnitEnum; -abstract class AbstractQueryProviderTest extends TestCase +abstract class AbstractQueryProvider extends TestCase { private $testObjectType; private $testObjectType2; @@ -455,7 +455,7 @@ protected function getTypeRegistry(): TypeRegistry return $this->typeRegistry; } - protected function resolveType(string $type): \phpDocumentor\Reflection\Type + protected static function resolveType(string $type): \phpDocumentor\Reflection\Type { return (new PhpDocumentorTypeResolver())->resolve($type); } diff --git a/tests/AggregateControllerQueryProviderTest.php b/tests/AggregateControllerQueryProviderTest.php index edabec397b..93832bdbba 100644 --- a/tests/AggregateControllerQueryProviderTest.php +++ b/tests/AggregateControllerQueryProviderTest.php @@ -5,7 +5,7 @@ use Psr\Container\ContainerInterface; use TheCodingMachine\GraphQLite\Fixtures\TestController; -class AggregateControllerQueryProviderTest extends AbstractQueryProviderTest +class AggregateControllerQueryProviderTest extends AbstractQueryProvider { public function testAggregate(): void { diff --git a/tests/Containers/BasicAutoWiringContainerTest.php b/tests/Containers/BasicAutoWiringContainerTest.php index 3ef646fdb9..a63d32ef87 100644 --- a/tests/Containers/BasicAutoWiringContainerTest.php +++ b/tests/Containers/BasicAutoWiringContainerTest.php @@ -5,10 +5,10 @@ namespace TheCodingMachine\GraphQLite\Containers; use Psr\Container\ContainerInterface; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Fixtures\TestType; -class BasicAutoWiringContainerTest extends AbstractQueryProviderTest +class BasicAutoWiringContainerTest extends AbstractQueryProvider { private function getContainer(): ContainerInterface { diff --git a/tests/Containers/LazyContainerTest.php b/tests/Containers/LazyContainerTest.php index 34637d4091..7c0b9e30f0 100644 --- a/tests/Containers/LazyContainerTest.php +++ b/tests/Containers/LazyContainerTest.php @@ -4,10 +4,10 @@ namespace TheCodingMachine\GraphQLite\Containers; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Fixtures\TestType; -final class LazyContainerTest extends AbstractQueryProviderTest +final class LazyContainerTest extends AbstractQueryProvider { private function getContainer(): LazyContainer { diff --git a/tests/FactoryContextTest.php b/tests/FactoryContextTest.php index c6baf1022e..fec72a6ddd 100644 --- a/tests/FactoryContextTest.php +++ b/tests/FactoryContextTest.php @@ -7,7 +7,7 @@ use TheCodingMachine\GraphQLite\Containers\EmptyContainer; use TheCodingMachine\GraphQLite\Fixtures\Inputs\Validator; -class FactoryContextTest extends AbstractQueryProviderTest +class FactoryContextTest extends AbstractQueryProvider { const GLOB_TTL_SECONDS = 2; diff --git a/tests/FieldsBuilderTest.php b/tests/FieldsBuilderTest.php index 0b3bcf2c00..630b63c7bc 100644 --- a/tests/FieldsBuilderTest.php +++ b/tests/FieldsBuilderTest.php @@ -74,7 +74,7 @@ use TheCodingMachine\GraphQLite\Types\DateTimeType; use TheCodingMachine\GraphQLite\Types\VoidType; -class FieldsBuilderTest extends AbstractQueryProviderTest +class FieldsBuilderTest extends AbstractQueryProvider { public function testQueryProvider(): void { diff --git a/tests/GlobControllerQueryProviderTest.php b/tests/GlobControllerQueryProviderTest.php index dedc7d36ab..25f824b2f5 100644 --- a/tests/GlobControllerQueryProviderTest.php +++ b/tests/GlobControllerQueryProviderTest.php @@ -11,7 +11,7 @@ use Symfony\Component\Cache\Psr16Cache; use TheCodingMachine\GraphQLite\Fixtures\TestController; -class GlobControllerQueryProviderTest extends AbstractQueryProviderTest +class GlobControllerQueryProviderTest extends AbstractQueryProvider { public function testGlob(): void { diff --git a/tests/InputTypeUtilsTest.php b/tests/InputTypeUtilsTest.php index af27a9e27f..55e90adeab 100644 --- a/tests/InputTypeUtilsTest.php +++ b/tests/InputTypeUtilsTest.php @@ -10,7 +10,7 @@ use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; use TheCodingMachine\GraphQLite\Parameters\SourceParameter; -class InputTypeUtilsTest extends AbstractQueryProviderTest +class InputTypeUtilsTest extends AbstractQueryProvider { public function testNoReturnType(): void diff --git a/tests/Integration/QueryComplexityTest.php b/tests/Integration/QueryComplexityTest.php index fd2fbe95f6..23ffddb569 100644 --- a/tests/Integration/QueryComplexityTest.php +++ b/tests/Integration/QueryComplexityTest.php @@ -6,6 +6,7 @@ use GraphQL\GraphQL; use GraphQL\Validator\DocumentValidator; use GraphQL\Validator\Rules\QueryComplexity; +use PHPUnit\Framework\Attributes\DataProvider; use TheCodingMachine\GraphQLite\Schema; class QueryComplexityTest extends IntegrationTestCase @@ -58,9 +59,7 @@ public function testExceedsAllowedQueryComplexity(): void $this->assertSame('Max query complexity should be 5 but got 60.', $result->toArray(DebugFlag::RETHROW_UNSAFE_EXCEPTIONS)['errors'][0]['message']); } - /** - * @dataProvider calculatesCorrectQueryCostProvider - */ + #[DataProvider('calculatesCorrectQueryCostProvider')] public function testCalculatesCorrectQueryCost(int $expectedCost, string $query): void { $schema = $this->mainContainer->get(Schema::class); @@ -140,9 +139,7 @@ public static function calculatesCorrectQueryCostProvider(): iterable ]; } - /** - * @dataProvider reportsQueryCostInIntrospectionProvider - */ + #[DataProvider('reportsQueryCostInIntrospectionProvider')] public function testReportsQueryCostInIntrospection(string|null $expectedDescription, string $typeName, string $fieldName): void { $schema = $this->mainContainer->get(Schema::class); diff --git a/tests/Mappers/CannotMapTypeTraitTest.php b/tests/Mappers/CannotMapTypeTraitTest.php index 1a48b9717a..6677e67b09 100644 --- a/tests/Mappers/CannotMapTypeTraitTest.php +++ b/tests/Mappers/CannotMapTypeTraitTest.php @@ -3,11 +3,11 @@ namespace TheCodingMachine\GraphQLite\Mappers; use ReflectionClass; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Fixtures\TestTypeId; use TheCodingMachine\GraphQLite\Fixtures\Types\FooExtendType; -class CannotMapTypeTraitTest extends AbstractQueryProviderTest +class CannotMapTypeTraitTest extends AbstractQueryProvider { public function testAddParamInfoSupphp74() diff --git a/tests/Mappers/CompositeTypeMapperTest.php b/tests/Mappers/CompositeTypeMapperTest.php index f26e46f916..c0a2fe297b 100644 --- a/tests/Mappers/CompositeTypeMapperTest.php +++ b/tests/Mappers/CompositeTypeMapperTest.php @@ -5,7 +5,7 @@ use GraphQL\Type\Definition\NamedType; use GraphQL\Type\Definition\OutputType; use GraphQL\Type\Definition\Type; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Fixtures\Mocks\MockResolvableInputObjectType; use TheCodingMachine\GraphQLite\Fixtures\TestObject; use GraphQL\Type\Definition\InputObjectType; @@ -14,7 +14,7 @@ use TheCodingMachine\GraphQLite\Types\MutableObjectType; use TheCodingMachine\GraphQLite\Types\ResolvableMutableInputInterface; -class CompositeTypeMapperTest extends AbstractQueryProviderTest +class CompositeTypeMapperTest extends AbstractQueryProvider { /** * @var CompositeTypeMapper diff --git a/tests/Mappers/GlobTypeMapperTest.php b/tests/Mappers/GlobTypeMapperTest.php index 937f71004e..ce8ab0aad4 100644 --- a/tests/Mappers/GlobTypeMapperTest.php +++ b/tests/Mappers/GlobTypeMapperTest.php @@ -7,7 +7,7 @@ use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Adapter\NullAdapter; use Symfony\Component\Cache\Psr16Cache; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Annotations\Exceptions\ClassNotFoundException; use TheCodingMachine\GraphQLite\Containers\LazyContainer; use TheCodingMachine\GraphQLite\FailedResolvingInputType; @@ -28,7 +28,7 @@ use GraphQL\Type\Definition\ObjectType; use TheCodingMachine\GraphQLite\Types\MutableObjectType; -class GlobTypeMapperTest extends AbstractQueryProviderTest +class GlobTypeMapperTest extends AbstractQueryProvider { public function testGlobTypeMapper(): void { diff --git a/tests/Mappers/Parameters/ContainerParameterMapperTest.php b/tests/Mappers/Parameters/ContainerParameterMapperTest.php index 37020c23f7..cbb261b42e 100644 --- a/tests/Mappers/Parameters/ContainerParameterMapperTest.php +++ b/tests/Mappers/Parameters/ContainerParameterMapperTest.php @@ -6,13 +6,13 @@ use phpDocumentor\Reflection\Type; use ReflectionMethod; use ReflectionParameter; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Annotations\Parameter; use TheCodingMachine\GraphQLite\Annotations\Autowire; use TheCodingMachine\GraphQLite\Annotations\ParameterAnnotations; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; -class ContainerParameterMapperTest extends AbstractQueryProviderTest +class ContainerParameterMapperTest extends AbstractQueryProvider { public function testMapParameter(): void diff --git a/tests/Mappers/Parameters/InjectUserParameterHandlerTest.php b/tests/Mappers/Parameters/InjectUserParameterHandlerTest.php index 927cf59028..4df3278575 100644 --- a/tests/Mappers/Parameters/InjectUserParameterHandlerTest.php +++ b/tests/Mappers/Parameters/InjectUserParameterHandlerTest.php @@ -4,18 +4,17 @@ use Generator; use phpDocumentor\Reflection\DocBlock; +use PHPUnit\Framework\Attributes\DataProvider; use ReflectionMethod; use stdClass; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Annotations\InjectUser; use TheCodingMachine\GraphQLite\Parameters\InjectUserParameter; use TheCodingMachine\GraphQLite\Security\AuthenticationServiceInterface; -class InjectUserParameterHandlerTest extends AbstractQueryProviderTest +class InjectUserParameterHandlerTest extends AbstractQueryProvider { - /** - * @dataProvider mapParameterProvider - */ + #[DataProvider('mapParameterProvider')] public function testMapParameter(bool $optional, string $method): void { $authenticationService = $this->createMock(AuthenticationServiceInterface::class); @@ -37,7 +36,7 @@ public function testMapParameter(bool $optional, string $method): void ); } - public function mapParameterProvider(): Generator + public static function mapParameterProvider(): Generator { yield 'required user' => [false, 'requiredUser']; yield 'optional user' => [true, 'optionalUser']; diff --git a/tests/Mappers/Parameters/PrefetchParameterMiddlewareTest.php b/tests/Mappers/Parameters/PrefetchParameterMiddlewareTest.php index bc40294dbd..15d8701c0b 100644 --- a/tests/Mappers/Parameters/PrefetchParameterMiddlewareTest.php +++ b/tests/Mappers/Parameters/PrefetchParameterMiddlewareTest.php @@ -10,7 +10,7 @@ use ReflectionClass; use ReflectionMethod; use ReflectionParameter; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Annotations\Autowire; use TheCodingMachine\GraphQLite\Annotations\ParameterAnnotations; use TheCodingMachine\GraphQLite\Annotations\Prefetch; @@ -22,7 +22,7 @@ use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; use TheCodingMachine\GraphQLite\Parameters\PrefetchDataParameter; -class PrefetchParameterMiddlewareTest extends AbstractQueryProviderTest +class PrefetchParameterMiddlewareTest extends AbstractQueryProvider { public function testIgnoresParametersWithoutPrefetchAttribute(): void { diff --git a/tests/Mappers/Parameters/TypeMapperTest.php b/tests/Mappers/Parameters/TypeMapperTest.php index 36d66746bc..dd1327dde5 100644 --- a/tests/Mappers/Parameters/TypeMapperTest.php +++ b/tests/Mappers/Parameters/TypeMapperTest.php @@ -9,7 +9,7 @@ use ReflectionMethod; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Psr16Cache; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Annotations\HideParameter; use TheCodingMachine\GraphQLite\Fixtures\UnionOutputType; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException; @@ -17,7 +17,7 @@ use TheCodingMachine\GraphQLite\Parameters\InputTypeParameter; use TheCodingMachine\GraphQLite\Reflection\CachedDocBlockFactory; -class TypeMapperTest extends AbstractQueryProviderTest +class TypeMapperTest extends AbstractQueryProvider { public function testMapScalarUnionException(): void diff --git a/tests/Mappers/PorpaginasTypeMapperTest.php b/tests/Mappers/PorpaginasTypeMapperTest.php index f4371c1b56..286465e659 100644 --- a/tests/Mappers/PorpaginasTypeMapperTest.php +++ b/tests/Mappers/PorpaginasTypeMapperTest.php @@ -8,11 +8,11 @@ use Porpaginas\Arrays\ArrayResult; use Porpaginas\Result; use RuntimeException; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Fixtures\Mocks\MockResolvableInputObjectType; use TheCodingMachine\GraphQLite\Types\MutableObjectType; -class PorpaginasTypeMapperTest extends AbstractQueryProviderTest +class PorpaginasTypeMapperTest extends AbstractQueryProvider { private function getPorpaginasTypeMapper(): PorpaginasTypeMapper { diff --git a/tests/Mappers/RecursiveTypeMapperTest.php b/tests/Mappers/RecursiveTypeMapperTest.php index 7cba67ae9d..6d67e3bdd1 100644 --- a/tests/Mappers/RecursiveTypeMapperTest.php +++ b/tests/Mappers/RecursiveTypeMapperTest.php @@ -12,7 +12,7 @@ use Symfony\Component\Cache\Psr16Cache; use Symfony\Component\Cache\Simple\ArrayCache; use Symfony\Component\Cache\Simple\NullCache; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Containers\LazyContainer; use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Filter; use TheCodingMachine\GraphQLite\Fixtures\Interfaces\ClassA; @@ -26,7 +26,7 @@ use TheCodingMachine\GraphQLite\Types\MutableObjectType; use TheCodingMachine\GraphQLite\Types\ResolvableMutableInputObjectType; -class RecursiveTypeMapperTest extends AbstractQueryProviderTest +class RecursiveTypeMapperTest extends AbstractQueryProvider { public function testMapClassToType(): void diff --git a/tests/Mappers/Root/BaseTypeMapperTest.php b/tests/Mappers/Root/BaseTypeMapperTest.php index 947fe76d86..9461178ed4 100644 --- a/tests/Mappers/Root/BaseTypeMapperTest.php +++ b/tests/Mappers/Root/BaseTypeMapperTest.php @@ -13,13 +13,14 @@ use phpDocumentor\Reflection\Types\Nullable; use phpDocumentor\Reflection\Types\Object_; use phpDocumentor\Reflection\Types\Resource_; +use PHPUnit\Framework\Attributes\DataProvider; use ReflectionMethod; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Fixtures\TestObject; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException; use TheCodingMachine\GraphQLite\Types\MutableObjectType; -class BaseTypeMapperTest extends AbstractQueryProviderTest +class BaseTypeMapperTest extends AbstractQueryProvider { public function testNullableToGraphQLInputType(): void { @@ -60,16 +61,15 @@ public function testUnmappableInputArray(): void /** * @param string $phpdocType * @param class-string $expectedItemType - * + * @param string|null $expectedWrappedItemType * @return void - * - * @dataProvider genericIterablesProvider */ + #[DataProvider('genericIterablesProvider')] public function testOutputGenericIterables(string $phpdocType, string $expectedItemType, ?string $expectedWrappedItemType = null): void { $typeMapper = $this->getRootTypeMapper(); - $result = $typeMapper->toGraphQLOutputType($this->resolveType($phpdocType), null, new ReflectionMethod(__CLASS__, 'testOutputGenericIterables'), new DocBlock()); + $result = $typeMapper->toGraphQLOutputType(self::resolveType($phpdocType), null, new ReflectionMethod(__CLASS__, 'testOutputGenericIterables'), new DocBlock()); $this->assertInstanceOf(NonNull::class, $result); $this->assertInstanceOf(ListOfType::class, $result->getWrappedType()); @@ -81,7 +81,7 @@ public function testOutputGenericIterables(string $phpdocType, string $expectedI } } - public function genericIterablesProvider(): iterable + public static function genericIterablesProvider(): iterable { yield '\ArrayIterator with nullable int item' => ['\ArrayIterator', IntType::class]; yield '\ArrayIterator with int item' => ['\ArrayIterator', NonNull::class, IntType::class]; diff --git a/tests/Mappers/Root/CompoundTypeMapperTest.php b/tests/Mappers/Root/CompoundTypeMapperTest.php index ae36608d9f..78c876e1e1 100644 --- a/tests/Mappers/Root/CompoundTypeMapperTest.php +++ b/tests/Mappers/Root/CompoundTypeMapperTest.php @@ -9,11 +9,11 @@ use phpDocumentor\Reflection\Types\String_; use ReflectionMethod; use RuntimeException; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException; use TheCodingMachine\GraphQLite\NamingStrategy; -class CompoundTypeMapperTest extends AbstractQueryProviderTest +class CompoundTypeMapperTest extends AbstractQueryProvider { public function testException1() { diff --git a/tests/Mappers/Root/IteratorTypeMapperTest.php b/tests/Mappers/Root/IteratorTypeMapperTest.php index a3da01b008..684e836b73 100644 --- a/tests/Mappers/Root/IteratorTypeMapperTest.php +++ b/tests/Mappers/Root/IteratorTypeMapperTest.php @@ -7,11 +7,11 @@ use GraphQL\Type\Definition\NonNull; use phpDocumentor\Reflection\DocBlock; use ReflectionMethod; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Fixtures\TestObject; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException; -class IteratorTypeMapperTest extends AbstractQueryProviderTest +class IteratorTypeMapperTest extends AbstractQueryProvider { public function testInputIterator(): void { @@ -20,14 +20,14 @@ public function testInputIterator(): void // A type like ArrayObject|int[] CAN be mapped to an output type, but NOT to an input type. $this->expectException(CannotMapTypeException::class); $this->expectExceptionMessage('cannot map class "ArrayObject" to a known GraphQL input type. Are you missing a @Factory annotation? If you have a @Factory annotation, is it in a namespace analyzed by GraphQLite?'); - $typeMapper->toGraphQLInputType($this->resolveType('ArrayObject|int[]'), null, 'foo', new ReflectionMethod(__CLASS__, 'testInputIterator'), new DocBlock()); + $typeMapper->toGraphQLInputType(self::resolveType('ArrayObject|int[]'), null, 'foo', new ReflectionMethod(__CLASS__, 'testInputIterator'), new DocBlock()); } public function testOutputNullableValueIterator(): void { $typeMapper = $this->getRootTypeMapper(); - $result = $typeMapper->toGraphQLOutputType($this->resolveType('ArrayObject|array'), null, new ReflectionMethod(__CLASS__, 'testInputIterator'), new DocBlock()); + $result = $typeMapper->toGraphQLOutputType(self::resolveType('ArrayObject|array'), null, new ReflectionMethod(__CLASS__, 'testInputIterator'), new DocBlock()); $this->assertInstanceOf(NonNull::class, $result); $this->assertInstanceOf(ListOfType::class, $result->getWrappedType()); $this->assertInstanceOf(IntType::class, $result->getWrappedType()->getWrappedType()); @@ -39,7 +39,7 @@ public function testMixIterableWithNonArrayType(): void $this->expectException(CannotMapTypeException::class); $this->expectExceptionMessage('"\ArrayObject" is iterable. Please provide a more specific type. For instance: \ArrayObject|User[].'); - $result = $typeMapper->toGraphQLOutputType($this->resolveType('ArrayObject|'.TestObject::class), null, new ReflectionMethod(__CLASS__, 'testInputIterator'), new DocBlock()); + $result = $typeMapper->toGraphQLOutputType(self::resolveType('ArrayObject|'.TestObject::class), null, new ReflectionMethod(__CLASS__, 'testInputIterator'), new DocBlock()); } public function testIterableWithTwoArrays(): void @@ -48,6 +48,6 @@ public function testIterableWithTwoArrays(): void $this->expectException(CannotMapTypeException::class); $this->expectExceptionMessage('"\ArrayObject" is iterable. Please provide a more specific type. For instance: \ArrayObject|User[].'); - $result = $typeMapper->toGraphQLOutputType($this->resolveType('ArrayObject|array|array'), null, new ReflectionMethod(__CLASS__, 'testInputIterator'), new DocBlock()); + $result = $typeMapper->toGraphQLOutputType(self::resolveType('ArrayObject|array|array'), null, new ReflectionMethod(__CLASS__, 'testInputIterator'), new DocBlock()); } } diff --git a/tests/Mappers/Root/MyCLabsEnumTypeMapperTest.php b/tests/Mappers/Root/MyCLabsEnumTypeMapperTest.php index 6c925d5499..ecd711c945 100644 --- a/tests/Mappers/Root/MyCLabsEnumTypeMapperTest.php +++ b/tests/Mappers/Root/MyCLabsEnumTypeMapperTest.php @@ -8,10 +8,10 @@ use phpDocumentor\Reflection\Types\Object_; use ReflectionMethod; use Symfony\Component\Cache\Adapter\ArrayAdapter; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException; -class MyCLabsEnumTypeMapperTest extends AbstractQueryProviderTest +class MyCLabsEnumTypeMapperTest extends AbstractQueryProvider { public function testObjectTypeHint(): void { diff --git a/tests/Mappers/Root/NullableTypeMapperAdapterTest.php b/tests/Mappers/Root/NullableTypeMapperAdapterTest.php index ffd2b26360..bfa2f0c4f7 100644 --- a/tests/Mappers/Root/NullableTypeMapperAdapterTest.php +++ b/tests/Mappers/Root/NullableTypeMapperAdapterTest.php @@ -12,17 +12,16 @@ use phpDocumentor\Reflection\DocBlock; use phpDocumentor\Reflection\Type; use phpDocumentor\Reflection\Types\Nullable; +use PHPUnit\Framework\Attributes\DataProvider; use ReflectionMethod; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Fixtures\TestObject; use TheCodingMachine\GraphQLite\Fixtures\TestObject2; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException; -class NullableTypeMapperAdapterTest extends AbstractQueryProviderTest +class NullableTypeMapperAdapterTest extends AbstractQueryProvider { - /** - * @dataProvider nullableVariationsProvider - */ + #[DataProvider('nullableVariationsProvider')] public function testMultipleCompound(callable $type): void { $compoundTypeMapper = $this->getRootTypeMapper(); @@ -31,14 +30,14 @@ public function testMultipleCompound(callable $type): void $this->assertNotInstanceOf(NonNull::class, $result); } - public function nullableVariationsProvider(): Generator + public static function nullableVariationsProvider(): Generator { yield 'php documentor generated from phpdoc' => [ - fn () => $this->resolveType(TestObject::class . '|' . TestObject2::class . '|null'), + fn () => self::resolveType(TestObject::class . '|' . TestObject2::class . '|null'), ]; yield 'type handler nullable wrapped native reflection union type' => [ - fn () => new Nullable($this->resolveType(TestObject::class . '|' . TestObject2::class . '|null')), + fn () => new Nullable(self::resolveType(TestObject::class . '|' . TestObject2::class . '|null')), ]; } @@ -48,7 +47,7 @@ public function testOnlyNull(): void $this->expectException(CannotMapTypeException::class); $this->expectExceptionMessage('type-hinting against null only in the PHPDoc is not allowed.'); - $compoundTypeMapper->toGraphQLOutputType($this->resolveType('null'), null, new ReflectionMethod(__CLASS__, 'testMultipleCompound'), new DocBlock()); + $compoundTypeMapper->toGraphQLOutputType(self::resolveType('null'), null, new ReflectionMethod(__CLASS__, 'testMultipleCompound'), new DocBlock()); } public function testOnlyNull2(): void @@ -57,7 +56,7 @@ public function testOnlyNull2(): void $this->expectException(CannotMapTypeException::class); $this->expectExceptionMessage('type-hinting against null only in the PHPDoc is not allowed.'); - $compoundTypeMapper->toGraphQLInputType($this->resolveType('null'), null, 'foo', new ReflectionMethod(__CLASS__, 'testMultipleCompound'), new DocBlock()); + $compoundTypeMapper->toGraphQLInputType(self::resolveType('null'), null, 'foo', new ReflectionMethod(__CLASS__, 'testMultipleCompound'), new DocBlock()); } public function testNonNullableReturnedByWrappedMapper(): void @@ -85,6 +84,6 @@ public function mapNameToType(string $typeName): NamedType&GraphQLType $this->expectException(CannotMapTypeException::class); $this->expectExceptionMessage('a type mapper returned a GraphQL\\Type\\Definition\\NonNull instance.'); - $typeMapper->toGraphQLOutputType($this->resolveType(TestObject::class . '|' . TestObject2::class . '|null'), null, new ReflectionMethod(__CLASS__, 'testMultipleCompound'), new DocBlock()); + $typeMapper->toGraphQLOutputType(self::resolveType(TestObject::class . '|' . TestObject2::class . '|null'), null, new ReflectionMethod(__CLASS__, 'testMultipleCompound'), new DocBlock()); } } diff --git a/tests/Mappers/StaticClassListTypeMapperTest.php b/tests/Mappers/StaticClassListTypeMapperTest.php index bb43c54f29..c69ede9964 100644 --- a/tests/Mappers/StaticClassListTypeMapperTest.php +++ b/tests/Mappers/StaticClassListTypeMapperTest.php @@ -6,12 +6,12 @@ use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Psr16Cache; use Symfony\Component\Cache\Simple\ArrayCache; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Containers\EmptyContainer; use TheCodingMachine\GraphQLite\GraphQLRuntimeException; use TheCodingMachine\GraphQLite\NamingStrategy; -class StaticClassListTypeMapperTest extends AbstractQueryProviderTest +class StaticClassListTypeMapperTest extends AbstractQueryProvider { public function testClassListException(): void { diff --git a/tests/Mappers/StaticTypeMapperTest.php b/tests/Mappers/StaticTypeMapperTest.php index b340d83c20..7918181a2b 100644 --- a/tests/Mappers/StaticTypeMapperTest.php +++ b/tests/Mappers/StaticTypeMapperTest.php @@ -9,7 +9,7 @@ use GraphQL\Type\Definition\Type; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Psr16Cache; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Containers\BasicAutoWiringContainer; use TheCodingMachine\GraphQLite\Containers\EmptyContainer; use TheCodingMachine\GraphQLite\Fixtures\Mocks\MockResolvableInputObjectType; @@ -22,7 +22,7 @@ use TheCodingMachine\GraphQLite\SchemaFactory; use TheCodingMachine\GraphQLite\Types\MutableObjectType; -class StaticTypeMapperTest extends AbstractQueryProviderTest +class StaticTypeMapperTest extends AbstractQueryProvider { /** * @var StaticTypeMapper diff --git a/tests/Middlewares/AuthorizationFieldMiddlewareTest.php b/tests/Middlewares/AuthorizationFieldMiddlewareTest.php index f756cae352..723824c290 100644 --- a/tests/Middlewares/AuthorizationFieldMiddlewareTest.php +++ b/tests/Middlewares/AuthorizationFieldMiddlewareTest.php @@ -4,7 +4,7 @@ use GraphQL\Type\Definition\FieldDefinition; use GraphQL\Type\Definition\Type; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Annotations\Exceptions\IncompatibleAnnotationsException; use TheCodingMachine\GraphQLite\Annotations\FailWith; use TheCodingMachine\GraphQLite\Annotations\HideIfUnauthorized; @@ -18,7 +18,7 @@ use TheCodingMachine\GraphQLite\Security\VoidAuthenticationService; use TheCodingMachine\GraphQLite\Security\VoidAuthorizationService; -class AuthorizationFieldMiddlewareTest extends AbstractQueryProviderTest +class AuthorizationFieldMiddlewareTest extends AbstractQueryProvider { public function testReturnsResolversValueWhenAuthorized(): void { diff --git a/tests/Middlewares/AuthorizationInputFieldMiddlewareTest.php b/tests/Middlewares/AuthorizationInputFieldMiddlewareTest.php index 783feab83b..604fbaa9e9 100644 --- a/tests/Middlewares/AuthorizationInputFieldMiddlewareTest.php +++ b/tests/Middlewares/AuthorizationInputFieldMiddlewareTest.php @@ -5,7 +5,7 @@ use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; use stdClass; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Annotations\HideIfUnauthorized; use TheCodingMachine\GraphQLite\Annotations\Logged; use TheCodingMachine\GraphQLite\Annotations\MiddlewareAnnotationInterface; @@ -19,7 +19,7 @@ use TheCodingMachine\GraphQLite\Security\VoidAuthenticationService; use TheCodingMachine\GraphQLite\Security\VoidAuthorizationService; -class AuthorizationInputFieldMiddlewareTest extends AbstractQueryProviderTest +class AuthorizationInputFieldMiddlewareTest extends AbstractQueryProvider { public function testReturnsResolversValueWhenAuthorized(): void { diff --git a/tests/Middlewares/CostFieldMiddlewareTest.php b/tests/Middlewares/CostFieldMiddlewareTest.php index c0e5a93631..b53659360c 100644 --- a/tests/Middlewares/CostFieldMiddlewareTest.php +++ b/tests/Middlewares/CostFieldMiddlewareTest.php @@ -4,9 +4,10 @@ use GraphQL\Type\Definition\FieldDefinition; use GraphQL\Type\Definition\Type; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use PHPUnit\Runner\Version; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Annotations\Cost; use TheCodingMachine\GraphQLite\Annotations\Exceptions\IncompatibleAnnotationsException; use TheCodingMachine\GraphQLite\Annotations\FailWith; @@ -39,9 +40,7 @@ public function testIgnoresNullField(): void self::assertNull($result); } - /** - * @dataProvider setsComplexityFunctionProvider - */ + #[DataProvider('setsComplexityFunctionProvider')] public function testSetsComplexityFunction(int $expectedComplexity, Cost $cost): void { $field = $this->stubField(); @@ -86,9 +85,7 @@ public static function setsComplexityFunctionProvider(): iterable } - /** - * @dataProvider addsCostInDescriptionProvider - */ + #[DataProvider('addsCostInDescriptionProvider')] public function testAddsCostInDescription(string $expectedDescription, Cost $cost): void { if (Version::series() === '8.5') { diff --git a/tests/Parameters/InjectUserParameterTest.php b/tests/Parameters/InjectUserParameterTest.php index f4d4993d29..c01a3f4eaf 100644 --- a/tests/Parameters/InjectUserParameterTest.php +++ b/tests/Parameters/InjectUserParameterTest.php @@ -4,6 +4,7 @@ use Generator; use GraphQL\Type\Definition\ResolveInfo; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use stdClass; use TheCodingMachine\GraphQLite\Middlewares\MissingAuthorizationException; @@ -11,9 +12,7 @@ class InjectUserParameterTest extends TestCase { - /** - * @dataProvider resolveReturnsUserProvider - */ + #[DataProvider('resolveReturnsUserProvider')] public function testResolveReturnsUser(stdClass|null $user, bool $optional): void { $authenticationService = $this->createMock(AuthenticationServiceInterface::class); @@ -30,7 +29,7 @@ public function testResolveReturnsUser(stdClass|null $user, bool $optional): voi self::assertSame($user, $resolved); } - public function resolveReturnsUserProvider(): Generator + public static function resolveReturnsUserProvider(): Generator { yield 'non optional and has user' => [new stdClass(), false]; yield 'optional and has user' => [new stdClass(), true]; diff --git a/tests/QueryFieldDescriptorTest.php b/tests/QueryFieldDescriptorTest.php index 7ad4a3ba9a..5626d4272a 100644 --- a/tests/QueryFieldDescriptorTest.php +++ b/tests/QueryFieldDescriptorTest.php @@ -3,15 +3,14 @@ namespace TheCodingMachine\GraphQLite; use GraphQL\Type\Definition\Type; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use stdClass; use TheCodingMachine\GraphQLite\Middlewares\ServiceResolver; class QueryFieldDescriptorTest extends TestCase { - /** - * @dataProvider withAddedCommentLineProvider - */ + #[DataProvider('withAddedCommentLineProvider')] public function testWithAddedCommentLine(string $expected, string|null $previous, string $added): void { $resolver = fn () => null; diff --git a/tests/RootTypeMapperFactoryContextTest.php b/tests/RootTypeMapperFactoryContextTest.php index e33665c307..a87f5b7b9a 100644 --- a/tests/RootTypeMapperFactoryContextTest.php +++ b/tests/RootTypeMapperFactoryContextTest.php @@ -10,7 +10,7 @@ use TheCodingMachine\GraphQLite\Mappers\Root\RootTypeMapperFactoryContext; use TheCodingMachine\GraphQLite\Utils\Namespaces\NS; -class RootTypeMapperFactoryContextTest extends AbstractQueryProviderTest +class RootTypeMapperFactoryContextTest extends AbstractQueryProvider { const GLOB_TTL_SECONDS = 2; diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index 3863b6d412..862a80b003 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -2,7 +2,7 @@ namespace TheCodingMachine\GraphQLite; -class SchemaTest extends AbstractQueryProviderTest +class SchemaTest extends AbstractQueryProvider { public function testEmptyQuery(): void diff --git a/tests/TypeGeneratorTest.php b/tests/TypeGeneratorTest.php index 9f4077bda5..131e4ec228 100644 --- a/tests/TypeGeneratorTest.php +++ b/tests/TypeGeneratorTest.php @@ -8,7 +8,7 @@ use TheCodingMachine\GraphQLite\Fixtures\TypeFoo; use TheCodingMachine\GraphQLite\Types\MutableObjectType; -class TypeGeneratorTest extends AbstractQueryProviderTest +class TypeGeneratorTest extends AbstractQueryProvider { private ContainerInterface$container; diff --git a/tests/Types/ArgumentResolverTest.php b/tests/Types/ArgumentResolverTest.php index 4a91a33b3d..2a1a29831f 100644 --- a/tests/Types/ArgumentResolverTest.php +++ b/tests/Types/ArgumentResolverTest.php @@ -7,9 +7,9 @@ use GraphQL\Type\Definition\Type; use InvalidArgumentException; use RuntimeException; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; -abstract class ArgumentResolverTest extends AbstractQueryProviderTest +class ArgumentResolverTest extends AbstractQueryProvider { public function testResolveArrayException(): void @@ -26,6 +26,11 @@ public function testResolveUnexpectedInputType(): void $argumentResolver = $this->getArgumentResolver(); $this->expectException(RuntimeException::class); - $argumentResolver->resolve(null, 42, null, $this->createMock(ResolveInfo::class), new class extends Type implements InputType {}); + $argumentResolver->resolve(null, 42, null, $this->createMock(ResolveInfo::class), new class extends Type implements InputType { + public function toString(): string + { + return 'foo'; + } + }); } } diff --git a/tests/Types/InputTypeTest.php b/tests/Types/InputTypeTest.php index 58026484e0..f3d18c9134 100644 --- a/tests/Types/InputTypeTest.php +++ b/tests/Types/InputTypeTest.php @@ -7,8 +7,9 @@ use GraphQL\Type\Definition\NonNull; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\StringType; +use PHPUnit\Framework\Attributes\Group; use Psr\Container\ContainerInterface; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Annotations\Exceptions\IncompatibleAnnotationsException; use TheCodingMachine\GraphQLite\FailedResolvingInputType; use TheCodingMachine\GraphQLite\Fixtures\Inputs\CircularInputA; @@ -21,7 +22,7 @@ use TheCodingMachine\GraphQLite\Fixtures\Inputs\TestOnlyConstruct; use TheCodingMachine\GraphQLite\Fixtures\Inputs\TypedFooBar; -class InputTypeTest extends AbstractQueryProviderTest +class InputTypeTest extends AbstractQueryProvider { public function testInputConfiguredCorrectly(): void { @@ -142,9 +143,7 @@ public function testResolvesCorrectlyWithOnlyConstruct(): void $this->assertEquals(200, $result->getBar()); } - /** - * @group PR-466 - */ + #[Group('PR-466')] public function testResolvesCorrectlyWithConstructorAndProperties(): void { $input = new InputType( diff --git a/tests/Types/ResolvableMutableInputObjectTypeTest.php b/tests/Types/ResolvableMutableInputObjectTypeTest.php index 7e55ddfadf..fe77d925f3 100644 --- a/tests/Types/ResolvableMutableInputObjectTypeTest.php +++ b/tests/Types/ResolvableMutableInputObjectTypeTest.php @@ -8,7 +8,7 @@ use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use stdClass; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException; use TheCodingMachine\GraphQLite\FieldsBuilder; use TheCodingMachine\GraphQLite\Fixtures\TestObject; @@ -18,7 +18,7 @@ use TheCodingMachine\GraphQLite\Parameters\MissingArgumentException; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; -class ResolvableMutableInputObjectTypeTest extends AbstractQueryProviderTest +class ResolvableMutableInputObjectTypeTest extends AbstractQueryProvider { public function testResolve(): void { diff --git a/tests/Types/TypeAnnotatedInterfaceTypeTest.php b/tests/Types/TypeAnnotatedInterfaceTypeTest.php index 8835644107..fb1efaaf1a 100644 --- a/tests/Types/TypeAnnotatedInterfaceTypeTest.php +++ b/tests/Types/TypeAnnotatedInterfaceTypeTest.php @@ -4,9 +4,9 @@ use GraphQL\Type\Definition\ResolveInfo; use InvalidArgumentException; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; -class TypeAnnotatedInterfaceTypeTest extends AbstractQueryProviderTest +class TypeAnnotatedInterfaceTypeTest extends AbstractQueryProvider { public function testResolveTypeException() diff --git a/tests/Types/UnionTypeTest.php b/tests/Types/UnionTypeTest.php index 122c326e57..4a5f7be46e 100644 --- a/tests/Types/UnionTypeTest.php +++ b/tests/Types/UnionTypeTest.php @@ -5,12 +5,12 @@ use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\StringType; use PHPUnit\Framework\TestCase; -use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; +use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\Fixtures\TestObject; use TheCodingMachine\GraphQLite\Fixtures\TestObject2; use TheCodingMachine\GraphQLite\NamingStrategy; -class UnionTypeTest extends AbstractQueryProviderTest +class UnionTypeTest extends AbstractQueryProvider { public function testConstructor(): void { diff --git a/tests/Utils/NsTest.php b/tests/Utils/NsTest.php index 84f92c1ccd..b46d8bfe6d 100644 --- a/tests/Utils/NsTest.php +++ b/tests/Utils/NsTest.php @@ -7,6 +7,7 @@ use Exception; use Kcs\ClassFinder\Finder\ComposerFinder; use Kcs\ClassFinder\Finder\FinderInterface; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Psr\SimpleCache\CacheInterface; use Psr\SimpleCache\InvalidArgumentException; @@ -38,7 +39,7 @@ protected function setUp(): void $this->globTTL = 10; } - /** @dataProvider loadsClassListProvider */ + #[DataProvider('loadsClassListProvider')] public function testLoadsClassList(array $expectedClasses, string $namespace): void { $ns = new NS( diff --git a/tests/Utils/PropertyAccessorTest.php b/tests/Utils/PropertyAccessorTest.php index c6bc9ecb99..6d1ed84723 100644 --- a/tests/Utils/PropertyAccessorTest.php +++ b/tests/Utils/PropertyAccessorTest.php @@ -3,6 +3,7 @@ namespace TheCodingMachine\GraphQLite\Utils; use Exception; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Contact; use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Post; @@ -14,9 +15,7 @@ class PropertyAccessorTest extends TestCase { - /** - * @dataProvider findGetterProvider - */ + #[DataProvider('findGetterProvider')] public function testFindGetter(mixed $expected, string $class, string $propertyName): void { self::assertSame($expected, PropertyAccessor::findGetter($class, $propertyName)); @@ -31,9 +30,7 @@ public static function findGetterProvider(): iterable yield 'undefined property' => [null, MagicGetterSetterType::class, 'twenty']; } - /** - * @dataProvider findSetterProvider - */ + #[DataProvider('findSetterProvider')] public function testFindSetter(mixed $expected, string $class, string $propertyName): void { self::assertSame($expected, PropertyAccessor::findSetter($class, $propertyName)); @@ -47,9 +44,7 @@ public static function findSetterProvider(): iterable yield 'undefined property' => [null, MagicGetterSetterType::class, 'twenty']; } - /** - * @dataProvider getValueProvider - */ + #[DataProvider('getValueProvider')] public function testGetValue(mixed $expected, object $object, string $propertyName, array $args = []): void { if ($expected instanceof Exception) { @@ -70,9 +65,7 @@ public static function getValueProvider(): iterable yield 'undefined property' => [AccessPropertyException::createForUnreadableProperty(GetterSetterType::class, 'twenty'), new GetterSetterType(), 'twenty']; } - /** - * @dataProvider setValueProvider - */ + #[DataProvider('setValueProvider')] public function testSetValue(mixed $expected, object $object, string $propertyName, mixed $value): void { if ($expected instanceof Exception) { From 02c6131656a35dc846529b773223175bc02134a1 Mon Sep 17 00:00:00 2001 From: Oleksandr Prypkhan Date: Mon, 1 Apr 2024 03:16:09 +0300 Subject: [PATCH 065/108] Fix promoted default values (#672) * Fix FieldsBuilder ignoring promoted properties' default values * Add test for promoted default value * Fix code style * Fix code style --- src/FieldsBuilder.php | 18 +++++++++++++++++- .../Controllers/ArticleController.php | 1 + .../Integration/Models/UpdateArticleInput.php | 1 + tests/Integration/EndToEndTest.php | 2 ++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/FieldsBuilder.php b/src/FieldsBuilder.php index 878f837c9c..2681f740e5 100644 --- a/src/FieldsBuilder.php +++ b/src/FieldsBuilder.php @@ -170,7 +170,7 @@ public function getInputFields(string $className, string $inputName, bool $isUpd $reflectorByFields = []; $inputFields = []; - $defaultProperties = $refClass->getDefaultProperties(); + $defaultProperties = $this->getClassDefaultProperties($refClass); $closestMatchingTypeClass = null; $parent = get_parent_class($refClass->getName()); @@ -1143,4 +1143,20 @@ private function getClassConstructParameterNames(ReflectionClass $refClass): arr return $names; } + + /** @return array */ + private function getClassDefaultProperties(ReflectionClass $refClass): array + { + $properties = $refClass->getDefaultProperties(); + + foreach ($refClass->getConstructor()?->getParameters() ?? [] as $parameter) { + if (! $parameter->isPromoted() || ! $parameter->isDefaultValueAvailable()) { + continue; + } + + $properties[$parameter->getName()] = $parameter->getDefaultValue(); + } + + return $properties; + } } diff --git a/tests/Fixtures/Integration/Controllers/ArticleController.php b/tests/Fixtures/Integration/Controllers/ArticleController.php index 723adc9878..28e874cfbc 100644 --- a/tests/Fixtures/Integration/Controllers/ArticleController.php +++ b/tests/Fixtures/Integration/Controllers/ArticleController.php @@ -41,6 +41,7 @@ public function updateArticle(UpdateArticleInput $input): Article { $article = new Article('test'); $article->magazine = $input->magazine; + $article->summary = $input->summary; return $article; } diff --git a/tests/Fixtures/Integration/Models/UpdateArticleInput.php b/tests/Fixtures/Integration/Models/UpdateArticleInput.php index ecd4f538f8..cfa2738152 100644 --- a/tests/Fixtures/Integration/Models/UpdateArticleInput.php +++ b/tests/Fixtures/Integration/Models/UpdateArticleInput.php @@ -13,6 +13,7 @@ public function __construct( #[Field] #[Security("magazine != 'NYTimes'")] public readonly string|null $magazine, + public readonly string $summary = 'default', ) { } diff --git a/tests/Integration/EndToEndTest.php b/tests/Integration/EndToEndTest.php index d8522cc275..fccd26d937 100644 --- a/tests/Integration/EndToEndTest.php +++ b/tests/Integration/EndToEndTest.php @@ -1911,6 +1911,7 @@ public function testEndToEndInputConstructor(): void magazine: "Test" }) { magazine + summary } } '; @@ -1922,6 +1923,7 @@ public function testEndToEndInputConstructor(): void $data = $this->getSuccessResult($result); $this->assertSame('Test', $data['updateArticle']['magazine']); + $this->assertSame('default', $data['updateArticle']['summary']); $queryString = ' mutation { updateArticle(input: { From 657790b6e2b15a72533defd0e5e84f6641a6a6be Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Apr 2024 17:31:25 -0400 Subject: [PATCH 066/108] Bump codecov/codecov-action from 4.0.1 to 4.1.1 (#676) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.0.1 to 4.1.1. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v4.0.1...v4.1.1) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 47b6c5a0fd..c6dc96710d 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -84,7 +84,7 @@ jobs: path: "build" overwrite: true - - uses: codecov/codecov-action@v4.0.1 # upload the coverage to codecov + - uses: codecov/codecov-action@v4.1.1 # upload the coverage to codecov with: fail_ci_if_error: false # optional (default = false) - Need CODECOV_TOKEN # Do not upload in forks, and only on php8.3, latest deps From 942b9af8e87070cce33ab70a4b24bba5b66cf010 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Fri, 12 Apr 2024 16:47:52 -0400 Subject: [PATCH 067/108] Updated docs for namespaces that are incompatible with the new class-finder lib (#680) --- website/docs/other-frameworks.mdx | 16 ++++++++-------- .../version-7.0.0/other-frameworks.mdx | 12 ++++++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/website/docs/other-frameworks.mdx b/website/docs/other-frameworks.mdx index 9402451116..4957449cc7 100644 --- a/website/docs/other-frameworks.mdx +++ b/website/docs/other-frameworks.mdx @@ -38,8 +38,8 @@ use TheCodingMachine\GraphQLite\SchemaFactory; // $cache is a PSR-16 compatible cache // $container is a PSR-11 compatible container $factory = new SchemaFactory($cache, $container); -$factory->addControllerNamespace('App\\Controllers\\') - ->addTypeNamespace('App\\'); +$factory->addControllerNamespace('App\\Controllers') + ->addTypeNamespace('App'); $schema = $factory->createSchema(); ``` @@ -109,8 +109,8 @@ use Kcs\ClassFinder\Finder\ComposerFinder; use TheCodingMachine\GraphQLite\SchemaFactory; $factory = new SchemaFactory($cache, $container); -$factory->addControllerNamespace('App\\Controllers\\') - ->addTypeNamespace('App\\') +$factory->addControllerNamespace('App\\Controllers') + ->addTypeNamespace('App') ->setFinder( (new ComposerFinder())->useAutoloading(false) ); @@ -132,8 +132,8 @@ use TheCodingMachine\GraphQLite\Context\Context; // $cache is a PSR-16 compatible cache. // $container is a PSR-11 compatible container. $factory = new SchemaFactory($cache, $container); -$factory->addControllerNamespace('App\\Controllers\\') - ->addTypeNamespace('App\\'); +$factory->addControllerNamespace('App\\Controllers') + ->addTypeNamespace('App'); $schema = $factory->createSchema(); @@ -290,8 +290,8 @@ return new Picotainer([ Schema::class => function(ContainerInterface $container) { // The magic happens here. We create a schema using GraphQLite SchemaFactory. $factory = new SchemaFactory($container->get(CacheInterface::class), $container); - $factory->addControllerNamespace('App\\Controllers\\'); - $factory->addTypeNamespace('App\\'); + $factory->addControllerNamespace('App\\Controllers'); + $factory->addTypeNamespace('App'); return $factory->createSchema(); } ]); diff --git a/website/versioned_docs/version-7.0.0/other-frameworks.mdx b/website/versioned_docs/version-7.0.0/other-frameworks.mdx index 64806bd84a..16eb20e745 100644 --- a/website/versioned_docs/version-7.0.0/other-frameworks.mdx +++ b/website/versioned_docs/version-7.0.0/other-frameworks.mdx @@ -38,8 +38,8 @@ use TheCodingMachine\GraphQLite\SchemaFactory; // $cache is a PSR-16 compatible cache // $container is a PSR-11 compatible container $factory = new SchemaFactory($cache, $container); -$factory->addControllerNamespace('App\\Controllers\\') - ->addTypeNamespace('App\\'); +$factory->addControllerNamespace('App\\Controllers') + ->addTypeNamespace('App'); $schema = $factory->createSchema(); ``` @@ -112,8 +112,8 @@ use TheCodingMachine\GraphQLite\Context\Context; // $cache is a PSR-16 compatible cache. // $container is a PSR-11 compatible container. $factory = new SchemaFactory($cache, $container); -$factory->addControllerNamespace('App\\Controllers\\') - ->addTypeNamespace('App\\'); +$factory->addControllerNamespace('App\\Controllers') + ->addTypeNamespace('App'); $schema = $factory->createSchema(); @@ -266,8 +266,8 @@ return new Picotainer([ Schema::class => function(ContainerInterface $container) { // The magic happens here. We create a schema using GraphQLite SchemaFactory. $factory = new SchemaFactory($container->get(CacheInterface::class), $container); - $factory->addControllerNamespace('App\\Controllers\\'); - $factory->addTypeNamespace('App\\'); + $factory->addControllerNamespace('App\\Controllers'); + $factory->addTypeNamespace('App'); return $factory->createSchema(); } ]); From bcda9666c677cdd41b582c6069ab0e416549a9b9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Apr 2024 17:00:10 -0400 Subject: [PATCH 068/108] Bump codecov/codecov-action from 4.1.1 to 4.2.0 (#678) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.1.1 to 4.2.0. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v4.1.1...v4.2.0) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index c6dc96710d..02fa936a45 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -84,7 +84,7 @@ jobs: path: "build" overwrite: true - - uses: codecov/codecov-action@v4.1.1 # upload the coverage to codecov + - uses: codecov/codecov-action@v4.2.0 # upload the coverage to codecov with: fail_ci_if_error: false # optional (default = false) - Need CODECOV_TOKEN # Do not upload in forks, and only on php8.3, latest deps From 870b4489f5bf5d789005c04e838a87dbbe8ea53f Mon Sep 17 00:00:00 2001 From: Yurii <141632421+fogrye@users.noreply.github.com> Date: Sat, 13 Apr 2024 21:33:15 +0200 Subject: [PATCH 069/108] patch: drop DoctrineAnnotations (#677) * patch: drop DoctrineAnnotations BC: AnnotationReader constructor changed and it's methods stops triggering exceptions TestControllerWithInvalidParameterAnnotation deleted as `for` is ignored when `HideParameter` used as parameter attribute Also attribute exceptions and classes polished Fixes: #675 * fix: AnnotationReader condition * test: improve coverage * fix: drop parameters check Currently, each parameter attribute is assigned to method's parameter instead of method, makes this check useless * fix: code style * fix: code review * docs: updated with annotations removal * fix: static analysis --- composer.json | 1 - src/AnnotationReader.php | 271 +++---------- src/Annotations/Autowire.php | 35 +- src/Annotations/Decorate.php | 13 +- .../Exceptions/ClassNotFoundException.php | 4 +- .../Exceptions/InvalidParameterException.php | 7 +- src/Annotations/ExtendType.php | 11 +- src/Annotations/Factory.php | 9 +- src/Annotations/FailWith.php | 12 +- src/Annotations/Field.php | 12 - src/Annotations/HideIfUnauthorized.php | 5 +- src/Annotations/HideParameter.php | 19 +- src/Annotations/InjectUser.php | 13 +- src/Annotations/Input.php | 21 +- src/Annotations/Logged.php | 4 - src/Annotations/MagicField.php | 15 +- src/Annotations/Mutation.php | 7 - src/Annotations/Query.php | 7 - src/Annotations/Right.php | 9 +- src/Annotations/Security.php | 14 +- src/Annotations/SourceField.php | 15 +- src/Annotations/Subscription.php | 7 - src/Annotations/Type.php | 44 +- src/Annotations/UseInputType.php | 24 +- src/FieldsBuilder.php | 90 +++- src/Reflection/CachedDocBlockFactory.php | 5 +- src/SchemaFactory.php | 27 +- src/Types/TypeResolver.php | 3 +- tests/AbstractQueryProvider.php | 69 ++-- tests/AnnotationReaderTest.php | 128 ++---- tests/Annotations/AutowireTest.php | 2 +- tests/Annotations/DecorateTest.php | 2 +- tests/Annotations/ExtendTypeTest.php | 2 +- tests/Annotations/FailWithTest.php | 2 +- tests/Annotations/InputTest.php | 18 + tests/Annotations/RightTest.php | 2 +- tests/Annotations/SecurityTest.php | 4 +- tests/Annotations/TypeTest.php | 4 +- tests/Annotations/UseInputTypeTest.php | 4 +- tests/FieldsBuilderTest.php | 118 +++--- tests/Fixtures/AbstractTestController.php | 4 +- .../AnnotatedInterfaceController.php | 16 +- .../Types/BarInterface.php | 12 +- .../AnnotatedInterfaces/Types/ClassA.php | 8 +- .../AnnotatedInterfaces/Types/ClassC.php | 6 +- .../AnnotatedInterfaces/Types/ClassD.php | 9 +- .../Types/FooInterface.php | 12 +- .../Types/GrandFatherInterface.php | 12 +- .../Types/GrandMotherInterface.php | 13 +- .../Types/ParentInterfaceType.php | 9 +- .../Types/QuxInterface.php | 11 +- .../Types/WizzInterface.php | 12 +- .../ClassWithInvalidClassAnnotation.php | 9 +- .../ClassWithInvalidExtendTypeAnnotation.php | 2 +- .../ClassWithInvalidTypeAnnotation.php | 9 +- tests/Fixtures/BadClassType/TestType.php | 2 +- .../Fixtures/BadExtendType/BadExtendType.php | 5 +- .../BadExtendType2/BadExtendType2.php | 6 +- .../Controllers/CircularController.php | 8 +- .../Types/CircularInputA.php | 32 +- .../Types/CircularInputB.php | 30 +- .../DuplicateInputTypes/TestFactory.php | 9 +- .../DuplicateInputTypes/TestFactory2.php | 9 +- tests/Fixtures/DuplicateInputs/Foo.php | 6 +- tests/Fixtures/DuplicateInputs/FooInput.php | 6 +- .../TestControllerWithDuplicateQuery.php | 11 +- .../TestControllerWithDuplicateQuery1.php | 4 +- .../TestControllerWithDuplicateQuery2.php | 4 +- tests/Fixtures/DuplicateTypes/TestType.php | 5 +- tests/Fixtures/DuplicateTypes/TestType2.php | 5 +- .../AbstractTestFactory.php | 9 +- .../InheritedInputTypes/ChildTestFactory.php | 9 +- .../Controllers/InAndOutController.php | 12 +- .../Types/InAndOut.php | 21 +- tests/Fixtures/Inputs/FooBar.php | 36 +- tests/Fixtures/Inputs/InputInterface.php | 6 +- tests/Fixtures/Inputs/InputWithSetter.php | 29 +- .../Inputs/TestConstructorAndProperties.php | 37 +- tests/Fixtures/Inputs/TestOnlyConstruct.php | 32 +- tests/Fixtures/Inputs/TypedFooBar.php | 17 +- .../Controllers/ArticleController.php | 18 +- .../Controllers/ContactController.php | 24 +- .../Controllers/FilterController.php | 28 +- .../Controllers/PostController.php | 10 +- .../Controllers/PreferencesController.php | 2 +- .../Controllers/ProductController.php | 73 ++-- .../Controllers/SecurityController.php | 55 +-- tests/Fixtures/Integration/Models/Article.php | 21 +- tests/Fixtures/Integration/Models/Button.php | 36 +- tests/Fixtures/Integration/Models/Contact.php | 251 ++++-------- tests/Fixtures/Integration/Models/Filter.php | 2 +- tests/Fixtures/Integration/Models/Post.php | 104 ++--- .../Integration/Models/Preferences.php | 30 +- tests/Fixtures/Integration/Models/Product.php | 66 +-- .../Integration/Models/ProductTypeEnum.php | 5 +- .../Integration/Models/SpecialProduct.php | 39 +- .../Integration/Models/TrickyProduct.php | 123 ++---- tests/Fixtures/Integration/Models/User.php | 18 +- .../Integration/Types/ContactFactory.php | 24 +- .../Integration/Types/ContactOtherType.php | 14 +- .../Integration/Types/ContactType.php | 75 ++-- .../Types/ExtendedContactOtherType.php | 16 +- .../Integration/Types/ExtendedContactType.php | 26 +- .../Integration/Types/FilterDecorator.php | 34 +- tests/Fixtures/Interfaces/ClassA.php | 13 +- tests/Fixtures/Interfaces/ClassB.php | 12 +- .../Fixtures/Interfaces/Types/ClassAType.php | 10 +- .../Fixtures/Interfaces/Types/ClassBType.php | 8 +- .../NonInstantiableInput/AbstractFoo.php | 14 +- .../NonInstantiableType/AbstractFooType.php | 9 +- .../Controllers/TestLegacyController.php | 6 +- tests/Fixtures/TestController.php | 61 ++- tests/Fixtures/TestControllerNoReturnType.php | 9 +- .../Fixtures/TestControllerWithArrayParam.php | 3 +- .../TestControllerWithArrayReturnType.php | 5 +- .../TestControllerWithBadSecurity.php | 12 +- tests/Fixtures/TestControllerWithFailWith.php | 12 +- .../TestControllerWithInvalidInputType.php | 9 +- ...ntrollerWithInvalidParameterAnnotation.php | 19 - .../TestControllerWithInvalidReturnType.php | 4 +- .../TestControllerWithIterableParam.php | 4 +- .../TestControllerWithIterableReturnType.php | 4 +- .../TestControllerWithNullableArray.php | 2 +- .../TestControllerWithParamDateTime.php | 5 +- .../TestControllerWithReturnDateTime.php | 4 +- .../TestControllerWithUnionInputParam.php | 2 +- tests/Fixtures/TestDoubleReturnTag.php | 7 +- tests/Fixtures/TestFieldBadInputType.php | 13 +- tests/Fixtures/TestFieldBadOutputType.php | 9 +- tests/Fixtures/TestInput.php | 15 +- tests/Fixtures/TestObject.php | 25 +- tests/Fixtures/TestObject2.php | 12 +- tests/Fixtures/TestSelfType.php | 6 +- .../Fixtures/TestSourceFieldBadOutputType.php | 7 +- .../TestSourceFieldBadOutputType2.php | 7 +- tests/Fixtures/TestSourceName.php | 12 +- tests/Fixtures/TestSourceNameType.php | 11 +- tests/Fixtures/TestType.php | 24 +- tests/Fixtures/TestTypeId.php | 7 +- tests/Fixtures/TestTypeMissingAnnotation.php | 5 +- tests/Fixtures/TestTypeMissingField.php | 7 +- tests/Fixtures/TestTypeMissingReturnType.php | 7 +- tests/Fixtures/TestTypeWithDescriptions.php | 15 +- tests/Fixtures/TestTypeWithFailWith.php | 8 +- .../TestTypeWithInvalidPrefetchMethod.php | 4 +- tests/Fixtures/TestTypeWithMagicProperty.php | 10 +- .../TestTypeWithMagicPropertyType.php | 10 +- .../Fixtures/TestTypeWithPrefetchMethods.php | 19 +- .../TestTypeWithSourceFieldInterface.php | 7 +- ...hSourceFieldInvalidParameterAnnotation.php | 9 +- tests/Fixtures/TypeFoo.php | 9 +- tests/Fixtures/Types/AbstractFooType.php | 20 +- tests/Fixtures/Types/FooExtendType.php | 14 +- tests/Fixtures/Types/FooType.php | 7 +- tests/Fixtures/Types/TestFactory.php | 21 +- tests/Integration/EndToEndTest.php | 1 - tests/Integration/IntegrationTestCase.php | 3 +- tests/Mappers/GlobTypeMapperTest.php | 122 +++--- .../ContainerParameterMapperTest.php | 29 +- tests/Mappers/Parameters/TypeMapperTest.php | 45 +- tests/Mappers/RecursiveTypeMapperTest.php | 73 ++-- .../Mappers/StaticClassListTypeMapperTest.php | 3 +- tests/SchemaFactoryTest.php | 4 +- website/docs/CHANGELOG.md | 14 +- website/docs/README.mdx | 61 --- website/docs/annotations-reference.md | 152 +++---- website/docs/argument-resolving.md | 17 +- website/docs/authentication-authorization.mdx | 180 +------- website/docs/autowiring.mdx | 66 +-- website/docs/custom-types.mdx | 65 +-- .../docs/doctrine-annotations-attributes.mdx | 66 +-- website/docs/error-handling.mdx | 40 -- website/docs/extend-input-type.mdx | 79 +--- website/docs/extend-type.mdx | 142 +------ website/docs/external-type-declaration.mdx | 182 +-------- website/docs/field-middlewares.md | 28 +- website/docs/file-uploads.mdx | 31 -- website/docs/fine-grained-security.mdx | 272 +------------ website/docs/implementing-security.md | 4 +- website/docs/inheritance-interfaces.mdx | 158 +------- website/docs/input-types.mdx | 383 +----------------- website/docs/internals.md | 4 +- website/docs/laravel-package-advanced.mdx | 136 +------ website/docs/migrating.md | 10 +- website/docs/multiple-output-types.mdx | 161 +------- website/docs/mutations.mdx | 35 +- website/docs/other-frameworks.mdx | 40 +- website/docs/pagination.mdx | 34 -- website/docs/prefetch-method.mdx | 94 +---- website/docs/queries.mdx | 131 +----- website/docs/query-plan.mdx | 39 -- website/docs/symfony-bundle-advanced.mdx | 64 +-- website/docs/type-mapping.mdx | 338 +--------------- website/docs/validation.mdx | 117 +----- 194 files changed, 1431 insertions(+), 5193 deletions(-) create mode 100644 tests/Annotations/InputTest.php delete mode 100644 tests/Fixtures/TestControllerWithInvalidParameterAnnotation.php diff --git a/composer.json b/composer.json index e482e16693..5f89d4d2be 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,6 @@ "require": { "php": ">=8.1", "ext-json": "*", - "doctrine/annotations": "^1.14 || ^2.0", "composer/package-versions-deprecated": "^1.8", "phpdocumentor/reflection-docblock": "^4.3 || ^5.0", "phpdocumentor/type-resolver": "^1.4", diff --git a/src/AnnotationReader.php b/src/AnnotationReader.php index a65d15ac10..95efef9421 100644 --- a/src/AnnotationReader.php +++ b/src/AnnotationReader.php @@ -4,14 +4,10 @@ namespace TheCodingMachine\GraphQLite; -use Doctrine\Common\Annotations\AnnotationException; -use Doctrine\Common\Annotations\Reader; -use InvalidArgumentException; use ReflectionClass; use ReflectionMethod; use ReflectionParameter; use ReflectionProperty; -use RuntimeException; use TheCodingMachine\GraphQLite\Annotations\AbstractRequest; use TheCodingMachine\GraphQLite\Annotations\Decorate; use TheCodingMachine\GraphQLite\Annotations\EnumType; @@ -28,32 +24,17 @@ use TheCodingMachine\GraphQLite\Annotations\Type; use TheCodingMachine\GraphQLite\Annotations\TypeInterface; -use function array_diff_key; use function array_filter; use function array_key_exists; use function array_map; use function array_merge; -use function array_values; use function assert; use function count; -use function get_class; -use function in_array; use function is_a; use function reset; -use function str_contains; -use function str_starts_with; -use function strrpos; -use function substr; class AnnotationReader { - // In this mode, no exceptions will be thrown for incorrect annotations (unless the name of the annotation we are looking for is part of the docblock) - public const LAX_MODE = 'LAX_MODE'; - - // In this mode, exceptions will be thrown for any incorrect annotations. - public const STRICT_MODE = 'STRICT_MODE'; - - /** @var array */ private array $methodAnnotationCache = []; @@ -63,17 +44,6 @@ class AnnotationReader /** @var array> */ private array $propertyAnnotationsCache = []; - /** - * @param string $mode One of self::LAX_MODE or self::STRICT_MODE. If true, no exceptions will be thrown for incorrect annotations in code coming from the "vendor/" directory. - * @param array $strictNamespaces Classes in those namespaces MUST have valid annotations (otherwise, an error is thrown). - */ - public function __construct(private readonly Reader $reader, private readonly string $mode = self::STRICT_MODE, private readonly array $strictNamespaces = []) - { - if (! in_array($mode, [self::LAX_MODE, self::STRICT_MODE], true)) { - throw new InvalidArgumentException('The mode passed must be one of AnnotationReader::LAX_MODE, AnnotationReader::STRICT_MODE'); - } - } - /** * Returns a class annotation. Does not look in the parent class. * @@ -82,38 +52,25 @@ public function __construct(private readonly Reader $reader, private readonly st * * @return T|null * - * @throws AnnotationException * @throws ClassNotFoundException * * @template T of object */ private function getClassAnnotation(ReflectionClass $refClass, string $annotationClass): object|null { - try { - $attribute = $refClass->getAttributes($annotationClass)[0] ?? null; - if ($attribute) { - $instance = $attribute->newInstance(); - assert($instance instanceof $annotationClass); - return $instance; - } - $type = $this->reader->getClassAnnotation($refClass, $annotationClass); - assert($type === null || $type instanceof $annotationClass); - return $type; - } catch (AnnotationException $e) { - return match ($this->mode) { - self::STRICT_MODE=> throw $e, - self::LAX_MODE=>$this->isErrorImportant($annotationClass, $refClass->getDocComment() ?: '', $refClass->getName()) ? throw $e : null, - default=>throw new RuntimeException("Unexpected mode '" . $this->mode . "'.") // @codeCoverageIgnore - }; + $attribute = $refClass->getAttributes($annotationClass)[0] ?? null; + if (! $attribute) { + return null; } + $instance = $attribute->newInstance(); + assert($instance instanceof $annotationClass); + return $instance; } /** * Returns a method annotation and handles correctly errors. * * @param class-string $annotationClass - * - * @throws AnnotationException */ private function getMethodAnnotation(ReflectionMethod $refMethod, string $annotationClass): object|null { @@ -122,35 +79,15 @@ private function getMethodAnnotation(ReflectionMethod $refMethod, string $annota return $this->methodAnnotationCache[$cacheKey]; } - try { - $attribute = $refMethod->getAttributes($annotationClass)[0] ?? null; - if ($attribute) { - return $this->methodAnnotationCache[$cacheKey] = $attribute->newInstance(); - } - - return $this->methodAnnotationCache[$cacheKey] = $this->reader->getMethodAnnotation($refMethod, $annotationClass); - } catch (AnnotationException $e) { - return match ($this->mode) { - self::STRICT_MODE=> throw $e, - self::LAX_MODE=>$this->isErrorImportant($annotationClass, $refMethod->getDocComment() ?: '', $refMethod->getName()) ? throw $e : null, - default=>throw new RuntimeException("Unexpected mode '" . $this->mode . "'.") // @codeCoverageIgnore - }; - } - } + $attribute = $refMethod->getAttributes($annotationClass)[0] ?? null; + if (! $attribute) { + $this->methodAnnotationCache[$cacheKey] = null; - /** - * Returns true if the annotation class name is part of the docblock comment. - */ - private function isErrorImportant(string $annotationClass, string $docComment, string $className): bool - { - foreach ($this->strictNamespaces as $strictNamespace) { - if (str_starts_with($className, $strictNamespace)) { - return true; - } + return null; } - $shortAnnotationClass = substr($annotationClass, strrpos($annotationClass, '\\') + 1); + $this->methodAnnotationCache[$cacheKey] = $attribute->newInstance(); - return str_contains($docComment, '@' . $shortAnnotationClass); + return $this->methodAnnotationCache[$cacheKey]; } /** @@ -161,8 +98,6 @@ private function isErrorImportant(string $annotationClass, string $docComment, s * * @return A[] * - * @throws AnnotationException - * * @template T of object * @template A of object */ @@ -170,39 +105,22 @@ public function getClassAnnotations(ReflectionClass $refClass, string $annotatio { $toAddAnnotations = []; do { - try { - $allAnnotations = $this->reader->getClassAnnotations($refClass); - $toAddAnnotations[] = array_filter($allAnnotations, static function ($annotation) use ($annotationClass): bool { - return $annotation instanceof $annotationClass; - }); - - /** @var A[] $attributes */ - $attributes = array_map( - static function ($attribute) { - return $attribute->newInstance(); - }, - array_filter($refClass->getAttributes(), static function ($annotation) use ($annotationClass): bool { - return is_a($annotation->getName(), $annotationClass, true); - }), - ); - + /** @var A[] $attributes */ + $attributes = array_map( + static function ($attribute) { + return $attribute->newInstance(); + }, + array_filter($refClass->getAttributes(), static function ($annotation) use ($annotationClass): bool { + return is_a($annotation->getName(), $annotationClass, true); + }), + ); + if (count($attributes) > 0) { $toAddAnnotations[] = $attributes; - } catch (AnnotationException $e) { - if ($this->mode === self::STRICT_MODE) { - throw $e; - } - - if ( - ($this->mode === self::LAX_MODE) - && $this->isErrorImportant($annotationClass, $refClass->getDocComment() ?: '', $refClass->getName()) - ) { - throw $e; - } } $refClass = $refClass->getParentClass(); } while ($inherited && $refClass); - if (! empty($toAddAnnotations)) { + if (count($toAddAnnotations) > 0) { return array_merge(...$toAddAnnotations); } @@ -212,6 +130,8 @@ static function ($attribute) { /** * @param ReflectionClass $refClass * + * @throws ClassNotFoundException + * * @template T of object */ public function getTypeAnnotation(ReflectionClass $refClass): TypeInterface|null @@ -234,7 +154,7 @@ public function getTypeAnnotation(ReflectionClass $refClass): TypeInterface|null * * @return array * - * @throws AnnotationException + * @throws ClassNotFoundException * * @template T of object */ @@ -256,6 +176,8 @@ public function getInputAnnotations(ReflectionClass $refClass): array /** * @param ReflectionClass $refClass * + * @throws ClassNotFoundException + * * @template T of object */ public function getExtendTypeAnnotation(ReflectionClass $refClass): ExtendType|null @@ -314,34 +236,12 @@ public function getDecorateAnnotation(ReflectionMethod $refMethod): Decorate|nul return $decorateAnnotation; } - /** - * Only used in unit tests/ - * - * @deprecated Use getParameterAnnotationsPerParameter instead - * - * @throws AnnotationException - */ - public function getParameterAnnotations(ReflectionParameter $refParameter): ParameterAnnotations - { - $method = $refParameter->getDeclaringFunction(); - assert($method instanceof ReflectionMethod); - /** @var ParameterAnnotationInterface[] $parameterAnnotations */ - $parameterAnnotations = $this->getMethodAnnotations($method, ParameterAnnotationInterface::class); - $name = $refParameter->getName(); - - $filteredAnnotations = array_values(array_filter($parameterAnnotations, static function (ParameterAnnotationInterface $parameterAnnotation) use ($name) { - return $parameterAnnotation->getTarget() === $name; - })); - - return new ParameterAnnotations($filteredAnnotations); - } - /** * @param ReflectionParameter[] $refParameters * * @return array * - * @throws AnnotationException + * @throws InvalidParameterException */ public function getParameterAnnotationsPerParameter(array $refParameters): array { @@ -353,27 +253,6 @@ public function getParameterAnnotationsPerParameter(array $refParameters): array $method = $firstParam->getDeclaringFunction(); assert($method instanceof ReflectionMethod); - /** @var ParameterAnnotationInterface[] $parameterAnnotations */ - $parameterAnnotations = $this->getMethodAnnotations($method, ParameterAnnotationInterface::class); - - /** @var array> $parameterAnnotationsPerParameter */ - $parameterAnnotationsPerParameter = []; - foreach ($parameterAnnotations as $parameterAnnotation) { - $parameterAnnotationsPerParameter[$parameterAnnotation->getTarget()][] = $parameterAnnotation; - } - - // Let's check that the referenced parameters actually do exist: - $parametersByKey = []; - foreach ($refParameters as $refParameter) { - $parametersByKey[$refParameter->getName()] = true; - } - $diff = array_diff_key($parameterAnnotationsPerParameter, $parametersByKey); - if (count($diff) > 0) { - foreach ($diff as $parameterName => $parameterAnnotations) { - throw InvalidParameterException::parameterNotFound($parameterName, get_class($parameterAnnotations[0]), $method); - } - } - foreach ($refParameters as $refParameter) { $attributes = $refParameter->getAttributes(); $parameterAnnotationsPerParameter[$refParameter->getName()] = [...$parameterAnnotationsPerParameter[$refParameter->getName()] ?? @@ -398,7 +277,6 @@ static function (array $parameterAnnotations): ParameterAnnotations { ); } - /** @throws AnnotationException */ public function getMiddlewareAnnotations(ReflectionMethod|ReflectionProperty $reflection): MiddlewareAnnotations { if ($reflection instanceof ReflectionMethod) { @@ -417,47 +295,31 @@ public function getMiddlewareAnnotations(ReflectionMethod|ReflectionProperty $re * * @return array * - * @throws AnnotationException - * * @template T of object */ public function getMethodAnnotations(ReflectionMethod $refMethod, string $annotationClass): array { $cacheKey = $refMethod->getDeclaringClass()->getName() . '::' . $refMethod->getName() . '_s_' . $annotationClass; if (isset($this->methodAnnotationsCache[$cacheKey])) { - return $this->methodAnnotationsCache[$cacheKey]; - } - - $toAddAnnotations = []; - try { - $allAnnotations = $this->reader->getMethodAnnotations($refMethod); - $toAddAnnotations = array_filter($allAnnotations, static function ($annotation) use ($annotationClass): bool { - return $annotation instanceof $annotationClass; - }); - $attributes = $refMethod->getAttributes(); - $toAddAnnotations = [ - ...$toAddAnnotations, - ...array_map( - static function ($attribute) { - return $attribute->newInstance(); - }, - array_filter($attributes, static function ($annotation) use ($annotationClass): bool { - return is_a($annotation->getName(), $annotationClass, true); - }), - ), - ]; - } catch (AnnotationException $e) { - if ($this->mode === self::STRICT_MODE) { - throw $e; - } + /** @var array $annotations */ + $annotations = $this->methodAnnotationsCache[$cacheKey]; - if ($this->mode === self::LAX_MODE) { - if ($this->isErrorImportant($annotationClass, $refMethod->getDocComment() ?: '', $refMethod->getDeclaringClass()->getName())) { - throw $e; - } - } + return $annotations; } + $attributes = $refMethod->getAttributes(); + /** @var array $toAddAnnotations */ + $toAddAnnotations = [ + ...array_map( + static function ($attribute) { + return $attribute->newInstance(); + }, + array_filter($attributes, static function ($annotation) use ($annotationClass): bool { + return is_a($annotation->getName(), $annotationClass, true); + }), + ), + ]; + $this->methodAnnotationsCache[$cacheKey] = $toAddAnnotations; return $toAddAnnotations; @@ -470,8 +332,6 @@ static function ($attribute) { * * @return array * - * @throws AnnotationException - * * @template T of object */ public function getPropertyAnnotations(ReflectionProperty $refProperty, string $annotationClass): array @@ -484,35 +344,18 @@ public function getPropertyAnnotations(ReflectionProperty $refProperty, string $ return $annotations; } - $toAddAnnotations = []; - try { - $allAnnotations = $this->reader->getPropertyAnnotations($refProperty); - $toAddAnnotations = array_filter($allAnnotations, static function ($annotation) use ($annotationClass): bool { - return $annotation instanceof $annotationClass; - }); - $attributes = $refProperty->getAttributes(); - $toAddAnnotations = [ - ...$toAddAnnotations, - ...array_map( - static function ($attribute) { - return $attribute->newInstance(); - }, - array_filter($attributes, static function ($annotation) use ($annotationClass): bool { - return is_a($annotation->getName(), $annotationClass, true); - }), - ), - ]; - } catch (AnnotationException $e) { - if ($this->mode === self::STRICT_MODE) { - throw $e; - } - - if ($this->mode === self::LAX_MODE) { - if ($this->isErrorImportant($annotationClass, $refProperty->getDocComment() ?: '', $refProperty->getDeclaringClass()->getName())) { - throw $e; - } - } - } + $attributes = $refProperty->getAttributes(); + /** @var array $toAddAnnotations */ + $toAddAnnotations = [ + ...array_map( + static function ($attribute) { + return $attribute->newInstance(); + }, + array_filter($attributes, static function ($annotation) use ($annotationClass): bool { + return is_a($annotation->getName(), $annotationClass, true); + }), + ), + ]; $this->propertyAnnotationsCache[$cacheKey] = $toAddAnnotations; diff --git a/src/Annotations/Autowire.php b/src/Annotations/Autowire.php index 5974b96a6d..554b44f4ab 100644 --- a/src/Annotations/Autowire.php +++ b/src/Annotations/Autowire.php @@ -11,33 +11,28 @@ use function ltrim; /** - * Use this annotation to autowire a service from the container into a given parameter of a field/query/mutation. - * - * @Annotation - * @Target({"METHOD", "ANNOTATION"}) - * @Attributes({ - * @Attribute("for", type = "string"), - * @Attribute("identifier", type = "string") - * }) + * Use this attribute to autowire a service from the container into a given parameter of a field/query/mutation. */ #[Attribute(Attribute::TARGET_PARAMETER)] class Autowire implements ParameterAnnotationInterface { - /** @var string|null */ - private $for; - /** @var string|null */ - private $identifier; - - /** @param array|string $identifier */ - public function __construct(array|string $identifier = []) + private string|null $for = null; + private string|null $identifier = null; + + /** @param array|string $params */ + public function __construct( + array|string $params = [], + string|null $for = null, + string|null $identifier = null, + ) { - $values = $identifier; + $values = $params; if (is_string($values)) { $this->identifier = $values; } else { - $this->identifier = $values['identifier'] ?? $values['value'] ?? null; - if (isset($values['for'])) { - $this->for = ltrim($values['for'], '$'); + $this->identifier = $identifier ?? $values['identifier'] ?? $values['value'] ?? null; + if (isset($values['for']) || $for !== null) { + $this->for = ltrim($for ?? $values['for'], '$'); } } } @@ -45,7 +40,7 @@ public function __construct(array|string $identifier = []) public function getTarget(): string { if ($this->for === null) { - throw new BadMethodCallException('The @Autowire annotation must be passed a target. For instance: "@Autowire(for="$myService")"'); + throw new BadMethodCallException('The #[Autowire] attribute must be passed a target. For instance: "#[Autowire(for: "$myService")]"'); } return $this->for; } diff --git a/src/Annotations/Decorate.php b/src/Annotations/Decorate.php index ce9bc3b30f..a7ed1e36dc 100644 --- a/src/Annotations/Decorate.php +++ b/src/Annotations/Decorate.php @@ -10,20 +10,13 @@ use function is_string; /** - * Methods with this annotation are decorating an input type when the input type is resolved. + * Methods with this attribute are decorating an input type when the input type is resolved. * This is meant to be used only when the input type is provided by a third-party library and you want to modify it. - * - * @Annotation - * @Target({"METHOD"}) - * @Attributes({ - * @Attribute("inputTypeName", type = "string"), - * }) */ #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class Decorate { - /** @var string */ - private $inputTypeName; + private string $inputTypeName; /** * @param array|string $inputTypeName @@ -36,7 +29,7 @@ public function __construct(array|string $inputTypeName = []) if (is_string($values)) { $this->inputTypeName = $values; } elseif (! isset($values['value']) && ! isset($values['inputTypeName'])) { - throw new BadMethodCallException('The @Decorate annotation must be passed an input type. For instance: "@Decorate("MyInputType")"'); + throw new BadMethodCallException('The #[Decorate] attribute must be passed an input type. For instance: "#[Decorate("MyInputType")]"'); } else { $this->inputTypeName = $values['value'] ?? $values['inputTypeName']; } diff --git a/src/Annotations/Exceptions/ClassNotFoundException.php b/src/Annotations/Exceptions/ClassNotFoundException.php index 6cd1f9d6e4..aaa472e07a 100644 --- a/src/Annotations/Exceptions/ClassNotFoundException.php +++ b/src/Annotations/Exceptions/ClassNotFoundException.php @@ -17,11 +17,11 @@ public static function couldNotFindClass(string $className): self public static function wrapException(self $e, string $className): self { - return new self($e->getMessage() . " defined in @Type annotation of class '" . $className . "'"); + return new self($e->getMessage() . " defined in #[Type] attribute of class '" . $className . "'"); } public static function wrapExceptionForExtendTag(self $e, string $className): self { - return new self($e->getMessage() . " defined in @ExtendType annotation of class '" . $className . "'"); + return new self($e->getMessage() . " defined in #[ExtendType] attribute of class '" . $className . "'"); } } diff --git a/src/Annotations/Exceptions/InvalidParameterException.php b/src/Annotations/Exceptions/InvalidParameterException.php index 91d31a7ad0..86738a7ec3 100644 --- a/src/Annotations/Exceptions/InvalidParameterException.php +++ b/src/Annotations/Exceptions/InvalidParameterException.php @@ -11,13 +11,8 @@ class InvalidParameterException extends BadMethodCallException { - public static function parameterNotFound(string $parameter, string $annotationClass, ReflectionMethod $reflectionMethod): self - { - return new self(sprintf('Parameter "%s" declared in annotation "%s" of method "%s::%s()" does not exist.', $parameter, $annotationClass, $reflectionMethod->getDeclaringClass()->getName(), $reflectionMethod->getName())); - } - public static function parameterNotFoundFromSourceField(string $parameter, string $annotationClass, ReflectionMethod $reflectionMethod): self { - return new self(sprintf('Could not find parameter "%s" declared in annotation "%s". This annotation is itself declared in a SourceField annotation targeting resolver "%s::%s()".', $parameter, $annotationClass, $reflectionMethod->getDeclaringClass()->getName(), $reflectionMethod->getName())); + return new self(sprintf('Could not find parameter "%s" declared in annotation "%s". This annotation is itself declared in a SourceField attribute targeting resolver "%s::%s()".', $parameter, $annotationClass, $reflectionMethod->getDeclaringClass()->getName(), $reflectionMethod->getName())); } } diff --git a/src/Annotations/ExtendType.php b/src/Annotations/ExtendType.php index 40592090fd..609c141138 100644 --- a/src/Annotations/ExtendType.php +++ b/src/Annotations/ExtendType.php @@ -13,14 +13,7 @@ use function ltrim; /** - * The ExtendType annotation must be put in a GraphQL type class docblock and is used to add additional fields to the underlying PHP class. - * - * @Annotation - * @Target({"CLASS"}) - * @Attributes({ - * @Attribute("class", type = "string"), - * @Attribute("name", type = "string"), - * }) + * The ExtendType attribute must be put in a GraphQL type class docblock and is used to add additional fields to the underlying PHP class. */ #[Attribute(Attribute::TARGET_CLASS)] class ExtendType @@ -41,7 +34,7 @@ public function __construct(array $attributes = [], string|null $class = null, s $this->name = $name ?? $attributes['name'] ?? null; $this->class = $className; if (! $this->class && ! $this->name) { - throw new BadMethodCallException('In annotation @ExtendType, missing one of the compulsory parameter "class" or "name".'); + throw new BadMethodCallException('In attribute #[ExtendType], missing one of the compulsory parameter "class" or "name".'); } } diff --git a/src/Annotations/Factory.php b/src/Annotations/Factory.php index 457eb087ed..9d617e4744 100644 --- a/src/Annotations/Factory.php +++ b/src/Annotations/Factory.php @@ -9,13 +9,6 @@ /** * Factories are methods used to declare GraphQL input types. - * - * @Annotation - * @Target({"METHOD"}) - * @Attributes({ - * @Attribute("name", type = "string"), - * @Attribute("default", type = "bool") - * }) */ #[Attribute(Attribute::TARGET_METHOD)] class Factory @@ -33,7 +26,7 @@ public function __construct(array $attributes = [], string|null $name = null, bo $this->default = $default ?? $attributes['default'] ?? ! isset($attributes['name']); if ($this->name === null && $this->default === false) { - throw new GraphQLRuntimeException('A @Factory that has "default=false" attribute must be given a name (i.e. add a name="FooBarInput" attribute).'); + throw new GraphQLRuntimeException('A #[Factory] that has "default=false" attribute must be given a name (i.e. add a name="FooBarInput" attribute).'); } } diff --git a/src/Annotations/FailWith.php b/src/Annotations/FailWith.php index 7e153a5e50..9e87eb0d80 100644 --- a/src/Annotations/FailWith.php +++ b/src/Annotations/FailWith.php @@ -10,14 +10,6 @@ use function array_key_exists; use function is_array; -/** - * @Annotation - * @Target({"PROPERTY", "METHOD", "ANNOTATION"}) - * @Attributes({ - * @Attribute("value", type = "mixed"), - * @Attribute("mode", type = "string") - * }) - */ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)] class FailWith implements MiddlewareAnnotationInterface { @@ -35,8 +27,10 @@ public function __construct(mixed $values = [], mixed $value = '__fail__with__ma $this->value = $value; } elseif (is_array($values) && array_key_exists('value', $values)) { $this->value = $values['value']; + } elseif (! is_array($values)) { + $this->value = $values; } else { - throw new BadMethodCallException('The @FailWith annotation must be passed a defaultValue. For instance: "@FailWith(null)"'); + throw new BadMethodCallException('The #[FailWith] attribute must be passed a defaultValue. For instance: "#[FailWith(null)]"'); } } diff --git a/src/Annotations/Field.php b/src/Annotations/Field.php index 8e4112c535..12712bd6e8 100644 --- a/src/Annotations/Field.php +++ b/src/Annotations/Field.php @@ -10,18 +10,6 @@ use const E_USER_DEPRECATED; -/** - * @Annotation - * @Target({"PROPERTY", "METHOD"}) - * @Attributes({ - * @Attribute("name", type = "string"), - * @Attribute("outputType", type = "string"), - * @Attribute("prefetchMethod", type = "string"), - * @Attribute("for", type = "string[]"), - * @Attribute("description", type = "string"), - * @Attribute("inputType", type = "string"), - * }) - */ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class Field extends AbstractRequest { diff --git a/src/Annotations/HideIfUnauthorized.php b/src/Annotations/HideIfUnauthorized.php index 98727c7655..505ab18262 100644 --- a/src/Annotations/HideIfUnauthorized.php +++ b/src/Annotations/HideIfUnauthorized.php @@ -7,11 +7,8 @@ use Attribute; /** - * Fields/Queries/Mutations annotated with this annotation will be hidden from the schema if the user is not logged + * Fields/Queries/Mutations annotated with this attribute will be hidden from the schema if the user is not logged * or has no right associated. - * - * @Annotation - * @Target({"PROPERTY", "METHOD", "ANNOTATION"}) */ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)] class HideIfUnauthorized implements MiddlewareAnnotationInterface diff --git a/src/Annotations/HideParameter.php b/src/Annotations/HideParameter.php index 2c14fe4f7a..33c1328b8e 100644 --- a/src/Annotations/HideParameter.php +++ b/src/Annotations/HideParameter.php @@ -10,35 +10,28 @@ use function ltrim; /** - * Use this annotation to tell GraphQLite to ignore a parameter and not expose it as an input parameter. + * Use this attribute to tell GraphQLite to ignore a parameter and not expose it as an input parameter. * This is only possible if the parameter has a default value. - * - * @Annotation - * @Target({"METHOD", "ANNOTATION"}) - * @Attributes({ - * @Attribute("for", type = "string") - * }) */ #[Attribute(Attribute::TARGET_PARAMETER)] class HideParameter implements ParameterAnnotationInterface { - /** @var string */ - private $for; + private string|null $for = null; /** @param array $values */ - public function __construct(array $values = []) + public function __construct(array $values = [], string|null $for = null) { - if (! isset($values['for'])) { + if (! isset($values['for']) && $for === null) { return; } - $this->for = ltrim($values['for'], '$'); + $this->for = ltrim($for ?? $values['for'], '$'); } public function getTarget(): string { if ($this->for === null) { - throw new BadMethodCallException('The @HideParameter annotation must be passed a target. For instance: "@HideParameter(for="$myParameterToHide")"'); + throw new BadMethodCallException('The #[HideParameter] attribute must be passed a target. For instance: "#[HideParameter(for: "$myParameterToHide")]"'); } return $this->for; } diff --git a/src/Annotations/InjectUser.php b/src/Annotations/InjectUser.php index 7d050b3c52..bcdde944f7 100644 --- a/src/Annotations/InjectUser.php +++ b/src/Annotations/InjectUser.php @@ -10,20 +10,13 @@ use function ltrim; /** - * Use this annotation to tell GraphQLite to inject the current logged user as an input parameter. + * Use this attribute to tell GraphQLite to inject the current logged user as an input parameter. * If the parameter is not nullable, the user MUST be logged to access the resource. - * - * @Annotation - * @Target({"METHOD", "ANNOTATION"}) - * @Attributes({ - * @Attribute("for", type = "string") - * }) */ #[Attribute(Attribute::TARGET_PARAMETER)] class InjectUser implements ParameterAnnotationInterface { - /** @var string */ - private $for; + private string|null $for = null; /** @param array $values */ public function __construct(array $values = []) @@ -38,7 +31,7 @@ public function __construct(array $values = []) public function getTarget(): string { if ($this->for === null) { - throw new BadMethodCallException('The @InjectUser annotation must be passed a target. For instance: "@InjectUser(for="$user")"'); + throw new BadMethodCallException('The #[InjectUser] attribute must be passed a target. For instance: "#[InjectUser(for: "$user")]"'); } return $this->for; } diff --git a/src/Annotations/Input.php b/src/Annotations/Input.php index abe22a97d7..31066500a6 100644 --- a/src/Annotations/Input.php +++ b/src/Annotations/Input.php @@ -8,17 +8,8 @@ use RuntimeException; /** - * The Input annotation must be put in a GraphQL input type class docblock and is used to map to the underlying PHP class + * The Input attribute must be put in a GraphQL input type class docblock and is used to map to the underlying PHP class * this is exposed via this input type. - * - * @Annotation - * @Target({"CLASS"}) - * @Attributes({ - * @Attribute("name", type = "string"), - * @Attribute("default", type = "bool"), - * @Attribute("description", type = "string"), - * @Attribute("update", type = "bool"), - * }) */ #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] class Input implements TypeInterface @@ -56,16 +47,16 @@ public function __construct( public function getClass(): string { if ($this->class === null) { - throw new RuntimeException('Empty class for @Input annotation. You MUST create the Input annotation object using the GraphQLite AnnotationReader'); + throw new RuntimeException('Empty class for #[Input] attribute. You MUST create the Input attribute object using the GraphQLite AnnotationReader'); } return $this->class; } - /** @param class-string $class */ - public function setClass(string $class): void + /** @param class-string $className */ + public function setClass(string $className): void { - $this->class = $class; + $this->class = $className; } /** @@ -103,7 +94,7 @@ public function isUpdate(): bool /** * By default there isn't support for defining the type outside - * This is used by the @Type annotation with the "external" attribute. + * This is used by the #[Type] attribute with the "external" attribute. */ public function isSelfType(): bool { diff --git a/src/Annotations/Logged.php b/src/Annotations/Logged.php index e802dcd29d..b71400a8b7 100644 --- a/src/Annotations/Logged.php +++ b/src/Annotations/Logged.php @@ -6,10 +6,6 @@ use Attribute; -/** - * @Annotation - * @Target({"PROPERTY", "METHOD", "ANNOTATION"}) - */ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)] class Logged implements MiddlewareAnnotationInterface { diff --git a/src/Annotations/MagicField.php b/src/Annotations/MagicField.php index da0aff9979..650143ff2e 100644 --- a/src/Annotations/MagicField.php +++ b/src/Annotations/MagicField.php @@ -12,15 +12,6 @@ /** * SourceFields are fields that are directly source from the base object into GraphQL. - * - * @Annotation - * @Target({"CLASS"}) - * @Attributes({ - * @Attribute("name", type = "string"), - * @Attribute("outputType", type = "string"), - * @Attribute("phpType", type = "string"), - * @Attribute("annotations", type = "mixed"), - * }) */ #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] class MagicField implements SourceFieldInterface @@ -59,10 +50,10 @@ public function __construct(array $attributes = [], string|null $name = null, st $this->sourceName = $attributes['sourceName'] ?? $sourceName ?? null; if (! $this->name || (! $this->outputType && ! $this->phpType)) { - throw new BadMethodCallException('The @MagicField annotation must be passed a name and an output type or a php type. For instance: "@MagicField(name=\'phone\', outputType=\'String!\')" or "@MagicField(name=\'phone\', phpType=\'string\')"'); + throw new BadMethodCallException('The #[MagicField] attribute must be passed a name and an output type or a php type. For instance: "#[MagicField(name: \'phone\', outputType: \'String!\')]" or "#[MagicField(name: \'phone\', phpType: \'string\')]"'); } if (isset($this->outputType) && $this->phpType) { - throw new BadMethodCallException('In a @MagicField annotation, you cannot use the outputType and the phpType at the same time. For instance: "@MagicField(name=\'phone\', outputType=\'String!\')" or "@MagicField(name=\'phone\', phpType=\'string\')"'); + throw new BadMethodCallException('In a #[MagicField] attribute, you cannot use the outputType and the phpType at the same time. For instance: "#[MagicField(name: \'phone\', outputType: \'String!\')]" or "#[MagicField(name: \'phone\', phpType: \'string\')]"'); } $middlewareAnnotations = []; $parameterAnnotations = []; @@ -76,7 +67,7 @@ public function __construct(array $attributes = [], string|null $name = null, st } elseif ($annotation instanceof ParameterAnnotationInterface) { $parameterAnnotations[$annotation->getTarget()][] = $annotation; } else { - throw new BadMethodCallException('The @MagicField annotation\'s "annotations" attribute must be passed an array of annotations implementing either MiddlewareAnnotationInterface or ParameterAnnotationInterface."'); + throw new BadMethodCallException('The #[MagicField] attribute\'s "annotations" attribute must be passed an array of annotations implementing either MiddlewareAnnotationInterface or ParameterAnnotationInterface."'); } } $this->middlewareAnnotations = new MiddlewareAnnotations($middlewareAnnotations); diff --git a/src/Annotations/Mutation.php b/src/Annotations/Mutation.php index 060ee146b2..39db2aa594 100644 --- a/src/Annotations/Mutation.php +++ b/src/Annotations/Mutation.php @@ -6,13 +6,6 @@ use Attribute; -/** - * @Annotation - * @Target({"METHOD"}) - * @Attributes({ - * @Attribute("outputType", type = "string"), - * }) - */ #[Attribute(Attribute::TARGET_METHOD)] class Mutation extends AbstractRequest { diff --git a/src/Annotations/Query.php b/src/Annotations/Query.php index 99ac7a3a5d..8213ae5e10 100644 --- a/src/Annotations/Query.php +++ b/src/Annotations/Query.php @@ -6,13 +6,6 @@ use Attribute; -/** - * @Annotation - * @Target({"METHOD"}) - * @Attributes({ - * @Attribute("outputType", type = "string"), - * }) - */ #[Attribute(Attribute::TARGET_METHOD)] class Query extends AbstractRequest { diff --git a/src/Annotations/Right.php b/src/Annotations/Right.php index b58f8ebac0..d14597a326 100644 --- a/src/Annotations/Right.php +++ b/src/Annotations/Right.php @@ -9,13 +9,6 @@ use function is_string; -/** - * @Annotation - * @Target({"PROPERTY", "ANNOTATION", "METHOD"}) - * @Attributes({ - * @Attribute("name", type = "string"), - * }) - */ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class Right implements MiddlewareAnnotationInterface { @@ -34,7 +27,7 @@ public function __construct(array|string $name = []) $data = ['name' => $data]; } if (! isset($data['value']) && ! isset($data['name'])) { - throw new BadMethodCallException('The @Right annotation must be passed a right name. For instance: "@Right(\'my_right\')"'); + throw new BadMethodCallException('The #[Right] attribute must be passed a right name. For instance: "#[Right(\'my_right\')]"'); } $this->name = $data['value'] ?? $data['name']; } diff --git a/src/Annotations/Security.php b/src/Annotations/Security.php index 22381f3312..c41c396f60 100644 --- a/src/Annotations/Security.php +++ b/src/Annotations/Security.php @@ -14,16 +14,6 @@ use function is_string; use function sprintf; -/** - * @Annotation - * @Target({"PROPERTY", "ANNOTATION", "METHOD"}) - * @Attributes({ - * @Attribute("expression", type = "string"), - * @Attribute("failWith", type = "mixed"), - * @Attribute("statusCode", type = "int"), - * @Attribute("message", type = "string"), - * }) - */ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class Security implements MiddlewareAnnotationInterface { @@ -53,7 +43,7 @@ public function __construct(array|string $data = [], string|null $expression = n $this->expression = $data['value'] ?? $data['expression'] ?? $expression; if (! $this->expression) { - throw new BadMethodCallException('The @Security annotation must be passed an expression. For instance: "@Security("is_granted(\'CAN_EDIT_STUFF\')")"'); + throw new BadMethodCallException('The #[Security] attribute must be passed an expression. For instance: "#[Security("is_granted(\'CAN_EDIT_STUFF\')")]"'); } if (array_key_exists('failWith', $data)) { @@ -66,7 +56,7 @@ public function __construct(array|string $data = [], string|null $expression = n $this->message = $message ?? $data['message'] ?? 'Access denied.'; $this->statusCode = $statusCode ?? $data['statusCode'] ?? 403; if ($this->failWithIsSet === true && (($message || isset($data['message'])) || ($statusCode || isset($data['statusCode'])))) { - throw new BadMethodCallException('A @Security annotation that has "failWith" attribute set cannot have a message or a statusCode attribute.'); + throw new BadMethodCallException('A #[Security] attribute that has "failWith" attribute set cannot have a message or a statusCode attribute.'); } } diff --git a/src/Annotations/SourceField.php b/src/Annotations/SourceField.php index 492101d85d..cc5ceb6ad1 100644 --- a/src/Annotations/SourceField.php +++ b/src/Annotations/SourceField.php @@ -12,15 +12,6 @@ /** * SourceFields are fields that are directly source from the base object into GraphQL. - * - * @Annotation - * @Target({"CLASS"}) - * @Attributes({ - * @Attribute("name", type = "string"), - * @Attribute("outputType", type = "string"), - * @Attribute("phpType", type = "string"), - * @Attribute("annotations", type = "mixed"), - * }) */ #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] class SourceField implements SourceFieldInterface @@ -48,7 +39,7 @@ public function __construct(array $attributes = [], string|null $name = null, st { $name = $name ?? $attributes['name'] ?? null; if ($name === null) { - throw new BadMethodCallException('The @SourceField annotation must be passed a name. For instance: "@SourceField(name=\'phone\')"'); + throw new BadMethodCallException('The #[SourceField] attribute must be passed a name. For instance: "#[SourceField(name: \'phone\')]"'); } $this->name = $name; @@ -58,7 +49,7 @@ public function __construct(array $attributes = [], string|null $name = null, st $this->sourceName = $sourceName ?? $attributes['sourceName'] ?? null; if ($this->outputType && $this->phpType) { - throw new BadMethodCallException('In a @SourceField annotation, you cannot use the outputType and the phpType at the same time. For instance: "@SourceField(name=\'phone\', outputType=\'String!\')" or "@SourceField(name=\'phone\', phpType=\'string\')"'); + throw new BadMethodCallException('In a #[SourceField] attribute, you cannot use the outputType and the phpType at the same time. For instance: "#[SourceField(name: \'phone\', outputType: \'String!\')]" or "#[SourceField(name: \'phone\', phpType: \'string\')]"'); } $middlewareAnnotations = []; $parameterAnnotations = []; @@ -72,7 +63,7 @@ public function __construct(array $attributes = [], string|null $name = null, st } elseif ($annotation instanceof ParameterAnnotationInterface) { $parameterAnnotations[$annotation->getTarget()][] = $annotation; } else { - throw new BadMethodCallException('The @SourceField annotation\'s "annotations" attribute must be passed an array of annotations implementing either MiddlewareAnnotationInterface or ParameterAnnotationInterface."'); + throw new BadMethodCallException('The #[SourceField] attribute\'s "annotations" attribute must be passed an array of annotations implementing either MiddlewareAnnotationInterface or ParameterAnnotationInterface."'); } } $this->middlewareAnnotations = new MiddlewareAnnotations($middlewareAnnotations); diff --git a/src/Annotations/Subscription.php b/src/Annotations/Subscription.php index 1d5fce3b15..7da35e9661 100644 --- a/src/Annotations/Subscription.php +++ b/src/Annotations/Subscription.php @@ -6,13 +6,6 @@ use Attribute; -/** - * @Annotation - * @Target({"METHOD"}) - * @Attributes({ - * @Attribute("outputType", type = "string"), - * }) - */ #[Attribute(Attribute::TARGET_METHOD)] class Subscription extends AbstractRequest { diff --git a/src/Annotations/Type.php b/src/Annotations/Type.php index 0505eec76f..f8465782a5 100644 --- a/src/Annotations/Type.php +++ b/src/Annotations/Type.php @@ -14,39 +14,25 @@ use function ltrim; /** - * The Type annotation must be put in a GraphQL type class docblock and is used to map to the underlying PHP class + * The Type attribute must be put in a GraphQL type class attribute and is used to map to the underlying PHP class * this is exposed via this type. - * - * @Annotation - * @Target({"CLASS"}) - * @Attributes({ - * @Attribute("class", type = "string"), - * @Attribute("name", type = "string"), - * @Attribute("default", type = "bool"), - * @Attribute("external", type = "bool"), - * }) */ #[Attribute(Attribute::TARGET_CLASS)] class Type implements TypeInterface { /** @var class-string|null */ - private $class; + private string|null $class = null; - /** @var string|null */ - private $name; + private string|null $name = null; - /** @var bool */ - private $default; + private bool $default = true; /** - * Is the class having the annotation a GraphQL type itself? - * - * @var bool + * Is the class having the attribute a GraphQL type itself? */ - private $selfType = false; + private bool $selfType = false; - /** @var bool */ - private $useEnumValues = false; + private bool $useEnumValues = false; /** * @param mixed[] $attributes @@ -89,27 +75,27 @@ public function __construct( public function getClass(): string { if ($this->class === null) { - throw new RuntimeException('Empty class for @Type annotation. You MUST create the Type annotation object using the GraphQLite AnnotationReader'); + throw new RuntimeException('Empty class for #[Type] attribute. You MUST create the Type attribute object using the GraphQLite AnnotationReader'); } return $this->class; } - public function setClass(string $class): void + public function setClass(string $className): void { - $class = ltrim($class, '\\'); - $isInterface = interface_exists($class); - if (! class_exists($class) && ! $isInterface) { - throw ClassNotFoundException::couldNotFindClass($class); + $className = ltrim($className, '\\'); + $isInterface = interface_exists($className); + if (! class_exists($className) && ! $isInterface) { + throw ClassNotFoundException::couldNotFindClass($className); } - $this->class = $class; + $this->class = $className; if (! $isInterface) { return; } if ($this->default === false) { - throw new GraphQLRuntimeException('Problem in annotation @Type for interface "' . $class . '": you cannot use the default="false" attribute on interfaces'); + throw new GraphQLRuntimeException('Problem in attribute #[Type] for interface "' . $className . '": you cannot use the default="false" attribute on interfaces'); } } diff --git a/src/Annotations/UseInputType.php b/src/Annotations/UseInputType.php index 248270762b..6463d4ea1a 100644 --- a/src/Annotations/UseInputType.php +++ b/src/Annotations/UseInputType.php @@ -11,36 +11,30 @@ use function ltrim; /** - * Use this annotation to force using a specific input type for an input argument. - * - * @Annotation - * @Target({"METHOD", "ANNOTATION"}) - * @Attributes({ - * @Attribute("for", type = "string"), - * @Attribute("inputType", type = "string"), - * }) + * Use this attribute to force using a specific input type for an input argument. */ #[Attribute(Attribute::TARGET_PARAMETER)] class UseInputType implements ParameterAnnotationInterface { - /** @var string|null */ - private $for; - /** @var string */ - private $inputType; + private string|null $for = null; + private string $inputType; /** * @param array|string $inputType * * @throws BadMethodCallException */ - public function __construct(array|string $inputType = []) + public function __construct(array|string $inputType = [], string|null $for = null) { $values = $inputType; if (is_string($values)) { $values = ['inputType' => $values]; } + if (is_string($for) && $for !== '') { + $values['for'] = $for; + } if (! isset($values['inputType'])) { - throw new BadMethodCallException('The @UseInputType annotation must be passed an input type. For instance: #[UseInputType("MyInputType")]'); + throw new BadMethodCallException('The #[UseInputType] attribute must be passed an input type. For instance: #[UseInputType("MyInputType")]'); } $this->inputType = $values['inputType']; if (! isset($values['for'])) { @@ -53,7 +47,7 @@ public function __construct(array|string $inputType = []) public function getTarget(): string { if ($this->for === null) { - throw new BadMethodCallException('The @UseInputType annotation must be passed a target and an input type. For instance: #[UseInputType("MyInputType")]'); + throw new BadMethodCallException('The #[UseInputType] attribute must be passed a target and an input type. For instance: #[UseInputType("MyInputType")]'); } return $this->for; } diff --git a/src/FieldsBuilder.php b/src/FieldsBuilder.php index 2681f740e5..de99011166 100644 --- a/src/FieldsBuilder.php +++ b/src/FieldsBuilder.php @@ -4,7 +4,6 @@ namespace TheCodingMachine\GraphQLite; -use Doctrine\Common\Annotations\AnnotationException; use GraphQL\Type\Definition\FieldDefinition; use GraphQL\Type\Definition\InputType; use GraphQL\Type\Definition\NonNull; @@ -105,6 +104,10 @@ public function __construct( * @return array * * @throws ReflectionException + * @throws CannotMapTypeExceptionInterface + * @throws InvalidArgumentException + * + * @phpstan-ignore-next-line - simple-cache < 2.0 issue */ public function getQueries(object $controller): array { @@ -115,6 +118,10 @@ public function getQueries(object $controller): array * @return array * * @throws ReflectionException + * @throws CannotMapTypeExceptionInterface + * @throws InvalidArgumentException + * + * @phpstan-ignore-next-line - simple-cache < 2.0 issue */ public function getMutations(object $controller): array { @@ -125,13 +132,25 @@ public function getMutations(object $controller): array * @return array * * @throws ReflectionException + * @throws CannotMapTypeExceptionInterface + * @throws InvalidArgumentException + * + * @phpstan-ignore-next-line - simple-cache < 2.0 issue */ public function getSubscriptions(object $controller): array { return $this->getFieldsByAnnotations($controller, Subscription::class, false); } - /** @return array QueryField indexed by name. */ + /** + * @return array QueryField indexed by name. + * + * @throws ReflectionException + * @throws CannotMapTypeExceptionInterface + * @throws InvalidArgumentException + * + * @phpstan-ignore-next-line - simple-cache < 2.0 issue + */ public function getFields(object $controller, string|null $typeName = null): array { $fieldAnnotations = $this->getFieldsByAnnotations($controller, Annotations\Field::class, true, $typeName); @@ -159,8 +178,11 @@ public function getFields(object $controller, string|null $typeName = null): arr * * @return array * - * @throws AnnotationException * @throws ReflectionException + * @throws CannotMapTypeExceptionInterface + * @throws InvalidArgumentException + * + * @phpstan-ignore-next-line - simple-cache < 2.0 issue */ public function getInputFields(string $className, string $inputName, bool $isUpdate = false): array { @@ -247,6 +269,12 @@ public function getInputFields(string $className, string $inputName, bool $isUpd * @param class-string $className * * @return array QueryField indexed by name. + * + * @throws ReflectionException + * @throws CannotMapTypeExceptionInterface + * @throws InvalidArgumentException + * + * @phpstan-ignore-next-line - simple-cache < 2.0 issue */ public function getSelfFields(string $className, string|null $typeName = null): array { @@ -259,7 +287,6 @@ public function getSelfFields(string $className, string|null $typeName = null): $refClass = new ReflectionClass($className); - /** @var SourceFieldInterface[] $sourceFields */ $sourceFields = $this->annotationReader->getSourceFields($refClass); $fieldsFromSourceFields = $this->getQueryFieldsFromSourceFields($sourceFields, $refClass); @@ -277,6 +304,8 @@ public function getSelfFields(string $className, string|null $typeName = null): * @param int $skip Skip first N parameters if those are passed in externally * * @return array Returns an array of parameters. + * + * @throws InvalidArgumentException */ public function getParameters(ReflectionMethod $refMethod, int $skip = 0): array { @@ -292,6 +321,8 @@ public function getParameters(ReflectionMethod $refMethod, int $skip = 0): array * @param ReflectionMethod $refMethod A method annotated with a Decorate annotation. * * @return array Returns an array of parameters. + * + * @throws InvalidArgumentException */ public function getParametersForDecorator(ReflectionMethod $refMethod): array { @@ -308,6 +339,10 @@ public function getParametersForDecorator(ReflectionMethod $refMethod): array * @return array * * @throws ReflectionException + * @throws CannotMapTypeExceptionInterface + * @throws InvalidArgumentException + * + * @phpstan-ignore-next-line - simple-cache < 2.0 issue */ private function getFieldsByAnnotations($controller, string $annotationName, bool $injectSource, string|null $typeName = null): array { @@ -387,7 +422,10 @@ private function getFieldsByAnnotations($controller, string $annotationName, boo * * @return array * - * @throws AnnotationException + * @throws InvalidArgumentException + * @throws CannotMapTypeExceptionInterface + * + * @phpstan-ignore-next-line - simple-cache < 2.0 issue */ private function getFieldsByMethodAnnotations( string|object $controller, @@ -500,7 +538,10 @@ public function handle(QueryFieldDescriptor $fieldDescriptor): FieldDefinition|n * * @return array * - * @throws AnnotationException + * @throws InvalidArgumentException + * @throws CannotMapTypeException + * + * @phpstan-ignore-next-line - simple-cache < 2.0 issue */ private function getFieldsByPropertyAnnotations( string|object $controller, @@ -589,9 +630,12 @@ public function handle(QueryFieldDescriptor $fieldDescriptor): FieldDefinition|n * * @return FieldDefinition[] * - * @throws CannotMapTypeException * @throws CannotMapTypeExceptionInterface * @throws ReflectionException + * @throws FieldNotFoundException + * @throws InvalidArgumentException + * + * @phpstan-ignore-next-line - simple-cache < 2.0 issue */ private function getQueryFieldsFromSourceFields( array $sourceFields, @@ -744,7 +788,11 @@ private function getMagicGetMethodFromSourceClassOrProxy( return $sourceRefClass->getMethod($magicGet); } - /** @param ReflectionClass $refClass */ + /** + * @param ReflectionClass $refClass + * + * @throws CannotMapTypeExceptionInterface + */ private function resolveOutputType( string $outputType, ReflectionClass $refClass, @@ -759,7 +807,11 @@ private function resolveOutputType( } } - /** @param ReflectionClass $refClass */ + /** + * @param ReflectionClass $refClass + * + * @throws CannotMapTypeExceptionInterface + */ private function resolvePhpType( string $phpTypeStr, ReflectionClass $refClass, @@ -781,6 +833,9 @@ private function resolvePhpType( /** * @param ReflectionClass $reflectionClass * + * @throws ReflectionException + * @throws FieldNotFoundException + * * @template T of object */ private function getMethodFromPropertyName( @@ -804,6 +859,8 @@ private function getMethodFromPropertyName( * @param ReflectionParameter[] $refParameters * * @return array + * + * @throws InvalidParameterException */ private function mapParameters( array $refParameters, @@ -814,6 +871,7 @@ private function mapParameters( if (empty($refParameters)) { return []; } + $args = []; $additionalParameterAnnotations = $sourceField?->getParameterAnnotations() ?? []; @@ -933,11 +991,14 @@ private function getPrefetchParameter( * Gets input fields by class method annotations. * * @param class-string $annotationName - * @param array $defaultProperties + * @param array $defaultProperties * * @return array * - * @throws AnnotationException + * @throws CannotMapTypeExceptionInterface + * @throws InvalidArgumentException + * + * @phpstan-ignore-next-line - simple-cache < 2.0 issue */ private function getInputFieldsByMethodAnnotations( string|object $controller, @@ -1039,11 +1100,14 @@ public function handle(InputFieldDescriptor $inputFieldDescriptor): InputField|n * Gets input fields by class property annotations. * * @param class-string $annotationName - * @param array $defaultProperties + * @param array $defaultProperties * * @return array * - * @throws AnnotationException + * @throws InvalidArgumentException + * @throws CannotMapTypeException + * + * @phpstan-ignore-next-line - simple-cache < 2.0 issue */ private function getInputFieldsByPropertyAnnotations( string|object $controller, diff --git a/src/Reflection/CachedDocBlockFactory.php b/src/Reflection/CachedDocBlockFactory.php index 70b5c6699c..978e1e344d 100644 --- a/src/Reflection/CachedDocBlockFactory.php +++ b/src/Reflection/CachedDocBlockFactory.php @@ -6,6 +6,7 @@ use phpDocumentor\Reflection\DocBlock; use phpDocumentor\Reflection\DocBlockFactory; +use phpDocumentor\Reflection\DocBlockFactoryInterface; use phpDocumentor\Reflection\Types\Context; use phpDocumentor\Reflection\Types\ContextFactory; use Psr\SimpleCache\CacheInterface; @@ -24,7 +25,7 @@ */ class CachedDocBlockFactory { - private DocBlockFactory $docBlockFactory; + private DocBlockFactoryInterface $docBlockFactory; /** @var array */ private array $docBlockArrayCache = []; /** @var array */ @@ -32,7 +33,7 @@ class CachedDocBlockFactory private ContextFactory $contextFactory; /** @param CacheInterface $cache The cache we fetch data from. Note this is a SAFE cache. It does not need to be purged. */ - public function __construct(private readonly CacheInterface $cache, DocBlockFactory|null $docBlockFactory = null) + public function __construct(private readonly CacheInterface $cache, DocBlockFactoryInterface|null $docBlockFactory = null) { $this->docBlockFactory = $docBlockFactory ?: DocBlockFactory::createInstance(); $this->contextFactory = new ContextFactory(); diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index 04e4bfa6ba..a4c2bd2c6d 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -4,15 +4,11 @@ namespace TheCodingMachine\GraphQLite; -use Doctrine\Common\Annotations\AnnotationReader as DoctrineAnnotationReader; -use Doctrine\Common\Annotations\PsrCachedReader; -use Doctrine\Common\Annotations\Reader; use GraphQL\Type\SchemaConfig; use Kcs\ClassFinder\Finder\ComposerFinder; use Kcs\ClassFinder\Finder\FinderInterface; use MyCLabs\Enum\Enum; use PackageVersions\Versions; -use Psr\Cache\CacheItemPoolInterface; use Psr\Container\ContainerInterface; use Psr\SimpleCache\CacheInterface; use Symfony\Component\Cache\Adapter\Psr16Adapter; @@ -100,8 +96,6 @@ class SchemaFactory /** @var ParameterMiddlewareInterface[] */ private array $parameterMiddlewares = []; - private Reader|null $doctrineAnnotationReader = null; - private AuthenticationServiceInterface|null $authenticationService = null; private AuthorizationServiceInterface|null $authorizationService = null; @@ -126,7 +120,7 @@ class SchemaFactory private string $cacheNamespace; - public function __construct(private CacheInterface $cache, private ContainerInterface $container) + public function __construct(private readonly CacheInterface $cache, private readonly ContainerInterface $container) { $this->cacheNamespace = substr(md5(Versions::getVersion('thecodingmachine/graphqlite')), 0, 8); } @@ -211,23 +205,6 @@ public function addParameterMiddleware(ParameterMiddlewareInterface $parameterMi return $this; } - /** @deprecated Use PHP8 Attributes instead */ - public function setDoctrineAnnotationReader(Reader $annotationReader): self - { - $this->doctrineAnnotationReader = $annotationReader; - - return $this; - } - - /** - * Returns a cached Doctrine annotation reader. - * Note: we cannot get the annotation reader service in the container as we are in a compiler pass. - */ - private function getDoctrineAnnotationReader(CacheItemPoolInterface $cache): Reader - { - return $this->doctrineAnnotationReader ?? new PsrCachedReader(new DoctrineAnnotationReader(), $cache, true); - } - public function setAuthenticationService(AuthenticationServiceInterface $authenticationService): self { $this->authenticationService = $authenticationService; @@ -336,7 +313,7 @@ public function setExpressionLanguage(ExpressionLanguage $expressionLanguage): s public function createSchema(): Schema { $symfonyCache = new Psr16Adapter($this->cache, $this->cacheNamespace); - $annotationReader = new AnnotationReader($this->getDoctrineAnnotationReader($symfonyCache), AnnotationReader::LAX_MODE); + $annotationReader = new AnnotationReader(); $authenticationService = $this->authenticationService ?: new FailAuthenticationService(); $authorizationService = $this->authorizationService ?: new FailAuthorizationService(); $typeResolver = new TypeResolver(); diff --git a/src/Types/TypeResolver.php b/src/Types/TypeResolver.php index 5edb635a01..9e6e095837 100644 --- a/src/Types/TypeResolver.php +++ b/src/Types/TypeResolver.php @@ -12,6 +12,7 @@ use GraphQL\Type\Definition\WrappingType; use GraphQL\Type\Schema; use GraphQL\Utils\AST; +use JsonException; use RuntimeException; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeExceptionInterface; @@ -30,7 +31,7 @@ public function registerSchema(Schema $schema): void $this->schema = $schema; } - /** @throws CannotMapTypeExceptionInterface */ + /** @throws CannotMapTypeExceptionInterface|JsonException */ public function mapNameToType(string $typeName): Type { if ($this->schema === null) { diff --git a/tests/AbstractQueryProvider.php b/tests/AbstractQueryProvider.php index 37ca186cce..129068079c 100644 --- a/tests/AbstractQueryProvider.php +++ b/tests/AbstractQueryProvider.php @@ -1,14 +1,15 @@ [ 'test' => Type::string(), ], - ], function ($source, $args) { + ], static function ($source, $args) { return new TestObject($args['test']); }); } @@ -123,29 +122,18 @@ protected function getTypeMapper() $arrayAdapter = new ArrayAdapter(); $arrayAdapter->setLogger(new ExceptionLogger()); - $this->typeMapper = new RecursiveTypeMapper(new class($this->getTestObjectType(), $this->getTestObjectType2(), $this->getInputTestObjectType()/*, $this->getInputTestObjectType2()*/ - ) implements TypeMapperInterface { - /** - * @var ObjectType - */ + $this->typeMapper = new RecursiveTypeMapper(new class ($this->getTestObjectType(), $this->getTestObjectType2(), $this->getInputTestObjectType()/*, $this->getInputTestObjectType2()*/) implements TypeMapperInterface { + /** @var ObjectType */ private $testObjectType; - /** - * @var ObjectType - */ + /** @var ObjectType */ private $testObjectType2; - /** - * @var InputObjectType - */ + /** @var InputObjectType */ private $inputTestObjectType; - /** - * @var InputObjectType - */ - public function __construct( - ObjectType $testObjectType, - ObjectType $testObjectType2, - InputObjectType $inputTestObjectType + ObjectType $testObjectType, + ObjectType $testObjectType2, + InputObjectType $inputTestObjectType, ) { $this->testObjectType = $testObjectType; @@ -153,7 +141,7 @@ public function __construct( $this->inputTestObjectType = $inputTestObjectType; } - public function mapClassToType(string $className, ?OutputType $subType): MutableInterface + public function mapClassToType(string $className, OutputType|null $subType): MutableInterface { if ($className === TestObject::class) { return $this->testObjectType; @@ -234,7 +222,6 @@ public function decorateInputTypeForName(string $typeName, ResolvableMutableInpu { throw CannotMapTypeException::createForDecorateName($typeName, $type); } - }, new NamingStrategy(), new Psr16Cache($arrayAdapter), $this->getTypeRegistry(), $this->getAnnotationReader()); } return $this->typeMapper; @@ -264,7 +251,7 @@ protected function buildAutoWiringContainer(ContainerInterface $container): Basi protected function getAnnotationReader(): AnnotationReader { if ($this->annotationReader === null) { - $this->annotationReader = new AnnotationReader(new DoctrineAnnotationReader()); + $this->annotationReader = new AnnotationReader(); } return $this->annotationReader; } @@ -297,18 +284,20 @@ protected function buildFieldsBuilder(): FieldsBuilder $fieldMiddlewarePipe = new FieldMiddlewarePipe(); $fieldMiddlewarePipe->pipe(new AuthorizationFieldMiddleware( new VoidAuthenticationService(), - new VoidAuthorizationService() + new VoidAuthorizationService(), )); $expressionLanguage = new ExpressionLanguage( new Psr16Adapter($psr16Cache), - [new SecurityExpressionLanguageProvider()] + [new SecurityExpressionLanguageProvider()], ); $fieldMiddlewarePipe->pipe( - new SecurityFieldMiddleware($expressionLanguage, + new SecurityFieldMiddleware( + $expressionLanguage, new VoidAuthenticationService(), - new VoidAuthorizationService()) + new VoidAuthorizationService(), + ), ); $inputFieldMiddlewarePipe = new InputFieldMiddlewarePipe(); @@ -323,7 +312,7 @@ protected function buildFieldsBuilder(): FieldsBuilder $this->buildRootTypeMapper(), $parameterMiddlewarePipe, $fieldMiddlewarePipe, - $inputFieldMiddlewarePipe + $inputFieldMiddlewarePipe, ); $parameterizedCallableResolver = new ParameterizedCallableResolver($fieldsBuilder, $container); @@ -354,7 +343,7 @@ protected function buildRootTypeMapper(): RootTypeMapperInterface $rootTypeMapper = new BaseTypeMapper( $errorRootTypeMapper, $this->getTypeMapper(), - $topRootTypeMapper + $topRootTypeMapper, ); // Annotation support - deprecated @@ -362,14 +351,14 @@ protected function buildRootTypeMapper(): RootTypeMapperInterface $rootTypeMapper, $this->getAnnotationReader(), $arrayAdapter, - [] + [], ); $rootTypeMapper = new EnumTypeMapper( $rootTypeMapper, $this->getAnnotationReader(), $arrayAdapter, - [] + [], ); $rootTypeMapper = new CompoundTypeMapper( @@ -377,7 +366,7 @@ protected function buildRootTypeMapper(): RootTypeMapperInterface $topRootTypeMapper, new NamingStrategy(), $this->getTypeRegistry(), - $this->getTypeMapper() + $this->getTypeMapper(), ); $rootTypeMapper = new IteratorTypeMapper($rootTypeMapper, $topRootTypeMapper); @@ -407,7 +396,7 @@ protected function getTypeGenerator(): TypeGenerator $this->getTypeRegistry(), $this->getRegistry(), $this->getTypeMapper(), - $this->getFieldsBuilder() + $this->getFieldsBuilder(), ); return $this->typeGenerator; @@ -421,7 +410,7 @@ protected function getInputTypeGenerator(): InputTypeGenerator $this->inputTypeGenerator = new InputTypeGenerator( $this->getInputTypeUtils(), - $this->getFieldsBuilder() + $this->getFieldsBuilder(), ); return $this->inputTypeGenerator; @@ -432,7 +421,7 @@ protected function getInputTypeUtils(): InputTypeUtils if ($this->inputTypeUtils === null) { $this->inputTypeUtils = new InputTypeUtils( $this->getAnnotationReader(), - new NamingStrategy() + new NamingStrategy(), ); } return $this->inputTypeUtils; @@ -442,7 +431,7 @@ protected function getTypeResolver(): TypeResolver { if ($this->typeResolver === null) { $this->typeResolver = new TypeResolver(); - $this->typeResolver->registerSchema(new \GraphQL\Type\Schema([])); + $this->typeResolver->registerSchema(new Schema([])); } return $this->typeResolver; } diff --git a/tests/AnnotationReaderTest.php b/tests/AnnotationReaderTest.php index a476222c27..0ec8f2158e 100644 --- a/tests/AnnotationReaderTest.php +++ b/tests/AnnotationReaderTest.php @@ -1,14 +1,13 @@ expectException(InvalidArgumentException::class); - new AnnotationReader(new DoctrineAnnotationReader(), 'foo'); - } - - public function testStrictMode(): void + public function testBadAnnotation(): void { - $annotationReader = new AnnotationReader(new DoctrineAnnotationReader(), AnnotationReader::STRICT_MODE, []); - - $this->expectException(AnnotationException::class); - $annotationReader->getTypeAnnotation(new ReflectionClass(ClassWithInvalidClassAnnotation::class)); - } - - public function testLaxModeWithBadAnnotation(): void - { - $annotationReader = new AnnotationReader(new DoctrineAnnotationReader(), AnnotationReader::LAX_MODE, []); + $annotationReader = new AnnotationReader(); $type = $annotationReader->getTypeAnnotation(new ReflectionClass(ClassWithInvalidClassAnnotation::class)); $this->assertNull($type); } - public function testLaxModeWithSmellyAnnotation(): void + public function testSmellyAnnotation(): void { - $annotationReader = new AnnotationReader(new DoctrineAnnotationReader(), AnnotationReader::LAX_MODE, []); + $annotationReader = new AnnotationReader(); - $this->expectException(AnnotationException::class); - $annotationReader->getTypeAnnotation(new ReflectionClass(ClassWithInvalidTypeAnnotation::class)); + $this->assertNull($annotationReader->getTypeAnnotation(new ReflectionClass(ClassWithInvalidTypeAnnotation::class))); } - public function testLaxModeWithBadAnnotationAndStrictNamespace(): void + public function testGetAnnotationsWithBadAnnotation(): void { - $annotationReader = new AnnotationReader(new DoctrineAnnotationReader(), AnnotationReader::LAX_MODE, ['TheCodingMachine\\GraphQLite\\Fixtures']); - - $this->expectException(AnnotationException::class); - $annotationReader->getTypeAnnotation(new ReflectionClass(ClassWithInvalidClassAnnotation::class)); - } - - public function testGetAnnotationsStrictMode(): void - { - $annotationReader = new AnnotationReader(new DoctrineAnnotationReader(), AnnotationReader::STRICT_MODE, []); - - $this->expectException(AnnotationException::class); - $annotationReader->getClassAnnotations(new ReflectionClass(ClassWithInvalidClassAnnotation::class), Type::class); - } - - public function testGetAnnotationsLaxModeWithBadAnnotation(): void - { - $annotationReader = new AnnotationReader(new DoctrineAnnotationReader(), AnnotationReader::LAX_MODE, []); + $annotationReader = new AnnotationReader(); $types = $annotationReader->getClassAnnotations(new ReflectionClass(ClassWithInvalidClassAnnotation::class), Type::class); $this->assertSame([], $types); } - public function testGetAnnotationsLaxModeWithSmellyAnnotation(): void - { - $annotationReader = new AnnotationReader(new DoctrineAnnotationReader(), AnnotationReader::LAX_MODE, []); - - $this->expectException(AnnotationException::class); - $annotationReader->getClassAnnotations(new ReflectionClass(ClassWithInvalidTypeAnnotation::class), Type::class); - } - - public function testGetAnnotationsLaxModeWithBadAnnotationAndStrictNamespace(): void - { - $annotationReader = new AnnotationReader(new DoctrineAnnotationReader(), AnnotationReader::LAX_MODE, ['TheCodingMachine\\GraphQLite\\Fixtures']); - - $this->expectException(AnnotationException::class); - $annotationReader->getClassAnnotations(new ReflectionClass(ClassWithInvalidClassAnnotation::class), Type::class); - } - - public function testMethodStrictMode(): void + public function testMethodWithBadAnnotation(): void { - $annotationReader = new AnnotationReader(new DoctrineAnnotationReader(), AnnotationReader::STRICT_MODE, []); - - $this->expectException(AnnotationException::class); - $annotationReader->getRequestAnnotation(new ReflectionMethod(ClassWithInvalidClassAnnotation::class, 'testMethod'), Field::class); - } - - public function testMethodLaxModeWithBadAnnotation(): void - { - $annotationReader = new AnnotationReader(new DoctrineAnnotationReader(), AnnotationReader::LAX_MODE, []); + $annotationReader = new AnnotationReader(); $type = $annotationReader->getRequestAnnotation(new ReflectionMethod(ClassWithInvalidClassAnnotation::class, 'testMethod'), Field::class); $this->assertNull($type); } - public function testMethodLaxModeWithSmellyAnnotation(): void - { - $annotationReader = new AnnotationReader(new DoctrineAnnotationReader(), AnnotationReader::LAX_MODE, []); - - $this->expectException(AnnotationException::class); - $annotationReader->getRequestAnnotation(new ReflectionMethod(ClassWithInvalidTypeAnnotation::class, 'testMethod'), Field::class); - } - public function testExtendAnnotationException(): void { - $annotationReader = new AnnotationReader(new DoctrineAnnotationReader(), AnnotationReader::STRICT_MODE, []); + $annotationReader = new AnnotationReader(); $this->expectException(ClassNotFoundException::class); - $this->expectExceptionMessage("Could not autoload class 'foo' defined in @ExtendType annotation of class 'TheCodingMachine\GraphQLite\Fixtures\Annotations\ClassWithInvalidExtendTypeAnnotation'"); + $this->expectExceptionMessage("Could not autoload class 'foo' defined in #[ExtendType] attribute of class 'TheCodingMachine\GraphQLite\Fixtures\Annotations\ClassWithInvalidExtendTypeAnnotation'"); $annotationReader->getExtendTypeAnnotation(new ReflectionClass(ClassWithInvalidExtendTypeAnnotation::class)); } - public function testMethodsStrictMode(): void - { - $annotationReader = new AnnotationReader(new DoctrineAnnotationReader(), AnnotationReader::STRICT_MODE, []); - - $this->expectException(AnnotationException::class); - $annotationReader->getMethodAnnotations(new ReflectionMethod(ClassWithInvalidClassAnnotation::class, 'testMethod'), Field::class); - } - - public function testMethodsLaxModeWithBadAnnotation(): void + public function testMethodsWithBadAnnotation(): void { - $annotationReader = new AnnotationReader(new DoctrineAnnotationReader(), AnnotationReader::LAX_MODE, []); + $annotationReader = new AnnotationReader(); $type = $annotationReader->getMethodAnnotations(new ReflectionMethod(ClassWithInvalidClassAnnotation::class, 'testMethod'), Field::class); $this->assertSame([], $type); } - public function testGetMethodsAnnotationsLaxModeWithBadAnnotationAndStrictNamespace(): void - { - $annotationReader = new AnnotationReader(new DoctrineAnnotationReader(), AnnotationReader::LAX_MODE, ['TheCodingMachine\\GraphQLite\\Fixtures']); - - $this->expectException(AnnotationException::class); - $annotationReader->getMethodAnnotations(new ReflectionMethod(ClassWithInvalidClassAnnotation::class, 'testMethod'), Type::class); - } - public function testEmptyGetParameterAnnotations(): void { - $annotationReader = new AnnotationReader(new DoctrineAnnotationReader()); + $annotationReader = new AnnotationReader(); $this->assertEmpty($annotationReader->getParameterAnnotationsPerParameter([])); } public function testPhp8AttributeClassAnnotation(): void { - $annotationReader = new AnnotationReader(new DoctrineAnnotationReader()); + $annotationReader = new AnnotationReader(); $type = $annotationReader->getTypeAnnotation(new ReflectionClass(TestType::class)); $this->assertSame(TestType::class, $type->getClass()); @@ -168,7 +88,7 @@ public function testPhp8AttributeClassAnnotation(): void public function testPhp8AttributeClassAnnotations(): void { - $annotationReader = new AnnotationReader(new DoctrineAnnotationReader()); + $annotationReader = new AnnotationReader(); $types = $annotationReader->getSourceFields(new ReflectionClass(TestType::class)); @@ -177,7 +97,7 @@ public function testPhp8AttributeClassAnnotations(): void public function testPhp8AttributeMethodAnnotation(): void { - $annotationReader = new AnnotationReader(new DoctrineAnnotationReader()); + $annotationReader = new AnnotationReader(); $type = $annotationReader->getRequestAnnotation(new ReflectionMethod(TestType::class, 'getField'), Field::class); $this->assertInstanceOf(Field::class, $type); @@ -185,7 +105,7 @@ public function testPhp8AttributeMethodAnnotation(): void public function testPhp8AttributeMethodAnnotations(): void { - $annotationReader = new AnnotationReader(new DoctrineAnnotationReader()); + $annotationReader = new AnnotationReader(); $middlewareAnnotations = $annotationReader->getMiddlewareAnnotations(new ReflectionMethod(TestType::class, 'getField')); @@ -199,16 +119,16 @@ public function testPhp8AttributeMethodAnnotations(): void public function testPhp8AttributeParameterAnnotations(): void { - $annotationReader = new AnnotationReader(new DoctrineAnnotationReader()); + $annotationReader = new AnnotationReader(); - $parameterAnnotations = $annotationReader->getParameterAnnotationsPerParameter((new ReflectionMethod(__CLASS__, 'method1'))->getParameters()); + $parameterAnnotations = $annotationReader->getParameterAnnotationsPerParameter((new ReflectionMethod(self::class, 'method1'))->getParameters()); $this->assertInstanceOf(Autowire::class, $parameterAnnotations['dao']->getAnnotationByType(Autowire::class)); } private function method1( #[Autowire('myService')] - $dao + $dao, ): void { } } diff --git a/tests/Annotations/AutowireTest.php b/tests/Annotations/AutowireTest.php index acc8883e4f..9c68ffa332 100644 --- a/tests/Annotations/AutowireTest.php +++ b/tests/Annotations/AutowireTest.php @@ -11,7 +11,7 @@ class AutowireTest extends TestCase public function testException(): void { $this->expectException(BadMethodCallException::class); - $this->expectExceptionMessage('The @Autowire annotation must be passed a target. For instance: "@Autowire(for="$myService")"'); + $this->expectExceptionMessage('The #[Autowire] attribute must be passed a target. For instance: "#[Autowire(for: "$myService")]"'); (new Autowire([]))->getTarget(); } } diff --git a/tests/Annotations/DecorateTest.php b/tests/Annotations/DecorateTest.php index 083fff18d1..3015edbd4d 100644 --- a/tests/Annotations/DecorateTest.php +++ b/tests/Annotations/DecorateTest.php @@ -12,7 +12,7 @@ class DecorateTest extends TestCase public function testException(): void { $this->expectException(BadMethodCallException::class); - $this->expectExceptionMessage('The @Decorate annotation must be passed an input type. For instance: "@Decorate("MyInputType")"'); + $this->expectExceptionMessage('The #[Decorate] attribute must be passed an input type. For instance: "#[Decorate("MyInputType")]"'); new Decorate([]); } diff --git a/tests/Annotations/ExtendTypeTest.php b/tests/Annotations/ExtendTypeTest.php index 5530d7fa99..ab0d7627a3 100644 --- a/tests/Annotations/ExtendTypeTest.php +++ b/tests/Annotations/ExtendTypeTest.php @@ -11,7 +11,7 @@ class ExtendTypeTest extends TestCase public function testException(): void { $this->expectException(BadMethodCallException::class); - $this->expectExceptionMessage('In annotation @ExtendType, missing one of the compulsory parameter "class" or "name".'); + $this->expectExceptionMessage('In attribute #[ExtendType], missing one of the compulsory parameter "class" or "name".'); new ExtendType([]); } } diff --git a/tests/Annotations/FailWithTest.php b/tests/Annotations/FailWithTest.php index 79842c7029..468c9d73c7 100644 --- a/tests/Annotations/FailWithTest.php +++ b/tests/Annotations/FailWithTest.php @@ -12,7 +12,7 @@ class FailWithTest extends TestCase public function testException(): void { $this->expectException(BadMethodCallException::class); - $this->expectExceptionMessage('The @FailWith annotation must be passed a defaultValue. For instance: "@FailWith(null)"'); + $this->expectExceptionMessage('The #[FailWith] attribute must be passed a defaultValue. For instance: "#[FailWith(null)]"'); new FailWith([]); } diff --git a/tests/Annotations/InputTest.php b/tests/Annotations/InputTest.php new file mode 100644 index 0000000000..0fcdaa5b14 --- /dev/null +++ b/tests/Annotations/InputTest.php @@ -0,0 +1,18 @@ +expectException(RuntimeException::class); + $this->expectExceptionMessage('Empty class for #[Input] attribute. You MUST create the Input attribute object using the GraphQLite AnnotationReader'); + (new Input())->getClass(); + } +} diff --git a/tests/Annotations/RightTest.php b/tests/Annotations/RightTest.php index 4ca7fa86ed..0040e1061d 100644 --- a/tests/Annotations/RightTest.php +++ b/tests/Annotations/RightTest.php @@ -12,7 +12,7 @@ class RightTest extends TestCase public function testException(): void { $this->expectException(BadMethodCallException::class); - $this->expectExceptionMessage('The @Right annotation must be passed a right name. For instance: "@Right(\'my_right\')"'); + $this->expectExceptionMessage('The #[Right] attribute must be passed a right name. For instance: "#[Right(\'my_right\')]"'); new Right([]); } diff --git a/tests/Annotations/SecurityTest.php b/tests/Annotations/SecurityTest.php index 8ddafb5468..a35275092f 100644 --- a/tests/Annotations/SecurityTest.php +++ b/tests/Annotations/SecurityTest.php @@ -13,14 +13,14 @@ class SecurityTest extends TestCase public function testBadParams(): void { $this->expectException(BadMethodCallException::class); - $this->expectExceptionMessage('The @Security annotation must be passed an expression. For instance: "@Security("is_granted(\'CAN_EDIT_STUFF\')")"'); + $this->expectExceptionMessage('The #[Security] attribute must be passed an expression. For instance: "#[Security("is_granted(\'CAN_EDIT_STUFF\')")]"'); new Security([]); } public function testIncompatibleParams(): void { $this->expectException(BadMethodCallException::class); - $this->expectExceptionMessage('A @Security annotation that has "failWith" attribute set cannot have a message or a statusCode attribute.'); + $this->expectExceptionMessage('A #[Security] attribute that has "failWith" attribute set cannot have a message or a statusCode attribute.'); new Security(['expression'=>'foo', 'failWith'=>null, 'statusCode'=>500]); } } diff --git a/tests/Annotations/TypeTest.php b/tests/Annotations/TypeTest.php index a3909844b2..486befd85a 100644 --- a/tests/Annotations/TypeTest.php +++ b/tests/Annotations/TypeTest.php @@ -13,7 +13,7 @@ public function testException(): void { $type = new Type([]); $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Empty class for @Type annotation. You MUST create the Type annotation object using the GraphQLite AnnotationReader'); + $this->expectExceptionMessage('Empty class for #[Type] attribute. You MUST create the Type attribute object using the GraphQLite AnnotationReader'); $type->getClass(); } @@ -27,7 +27,7 @@ public function testException2() { $type = new Type(['default'=>false]); $this->expectException(GraphQLRuntimeException::class); - $this->expectExceptionMessage('Problem in annotation @Type for interface "TheCodingMachine\GraphQLite\Fixtures\AnnotatedInterfaces\Types\FooInterface": you cannot use the default="false" attribute on interfaces'); + $this->expectExceptionMessage('Problem in attribute #[Type] for interface "TheCodingMachine\GraphQLite\Fixtures\AnnotatedInterfaces\Types\FooInterface": you cannot use the default="false" attribute on interfaces'); $type->setClass(FooInterface::class); } } diff --git a/tests/Annotations/UseInputTypeTest.php b/tests/Annotations/UseInputTypeTest.php index 47cb85b329..b2815ecdd2 100644 --- a/tests/Annotations/UseInputTypeTest.php +++ b/tests/Annotations/UseInputTypeTest.php @@ -12,14 +12,14 @@ class UseInputTypeTest extends TestCase public function testException(): void { $this->expectException(BadMethodCallException::class); - $this->expectExceptionMessage('The @UseInputType annotation must be passed an input type. For instance: #[UseInputType("MyInputType")]'); + $this->expectExceptionMessage('The #[UseInputType] attribute must be passed an input type. For instance: #[UseInputType("MyInputType")]'); new UseInputType([]); } public function testException2(): void { $this->expectException(BadMethodCallException::class); - $this->expectExceptionMessage('The @UseInputType annotation must be passed a target and an input type. For instance: #[UseInputType("MyInputType")]'); + $this->expectExceptionMessage('The #[UseInputType] attribute must be passed a target and an input type. For instance: #[UseInputType("MyInputType")]'); (new UseInputType(['inputType' => 'foo']))->getTarget(); } diff --git a/tests/FieldsBuilderTest.php b/tests/FieldsBuilderTest.php index 630b63c7bc..ffcc0b522f 100644 --- a/tests/FieldsBuilderTest.php +++ b/tests/FieldsBuilderTest.php @@ -1,5 +1,7 @@ assertInstanceOf(EnumType::class, $usersQuery->args[9]->getType()); $this->assertSame('TestObjectInput', $usersQuery->args[1]->getType()->getWrappedType()->getWrappedType()->getWrappedType()->name); - $context = ['int' => 42, 'string' => 'foo', 'list' => [ - ['test' => 42], - ['test' => 12], - ], + $context = [ + 'int' => 42, + 'string' => 'foo', + 'list' => [ + ['test' => '42'], + ['test' => '12'], + ], 'boolean' => true, 'float' => 4.2, 'dateTimeImmutable' => '2017-01-01 01:01:01', 'dateTime' => '2017-01-01 01:01:01', 'id' => 42, - 'enum' => TestEnum::ON() + 'enum' => TestEnum::ON(), ]; $resolve = $usersQuery->resolveFn; @@ -147,7 +153,7 @@ public function testMutations(): void $resolve = $testReturnMutation->resolveFn; $result = $resolve( new stdClass(), - ['testObject' => ['test' => 42]], + ['testObject' => ['test' => '42']], null, $this->createMock(ResolveInfo::class), ); @@ -179,10 +185,7 @@ public function testSubscriptions(): void public function testErrors(): void { $controller = new class { - /** - * @Query - * @return string - */ + #[Query] public function test($noTypeHint): string { return 'foo'; @@ -198,12 +201,8 @@ public function test($noTypeHint): string public function testTypeInDocBlock(): void { $controller = new class { - /** - * @Query - * @param int $typeHintInDocBlock - * @return string - */ - public function test($typeHintInDocBlock) + #[Query] + public function test(int $typeHintInDocBlock): string { return 'foo'; } @@ -344,7 +343,7 @@ public function isLogged(): bool return true; } - public function getUser(): ?object + public function getUser(): object|null { return new stdClass(); } @@ -360,16 +359,15 @@ public function getUser(): ?object $this->getParameterMiddlewarePipe(), new AuthorizationFieldMiddleware( $authenticationService, - new VoidAuthorizationService() + new VoidAuthorizationService(), ), - new InputFieldMiddlewarePipe() + new InputFieldMiddlewarePipe(), ); - $fields = $queryProvider->getFields(new TestType(), true); + $fields = $queryProvider->getFields(new TestType()); $this->assertCount(4, $fields); $this->assertSame('testBool', $fields['testBool']->name); - } public function testRightInSourceField(): void @@ -392,16 +390,15 @@ public function isAllowed(string $right, $subject = null): bool $this->getParameterMiddlewarePipe(), new AuthorizationFieldMiddleware( new VoidAuthenticationService(), - $authorizationService + $authorizationService, ), - new InputFieldMiddlewarePipe() + new InputFieldMiddlewarePipe(), ); - $fields = $queryProvider->getFields(new TestType(), true); + $fields = $queryProvider->getFields(new TestType()); $this->assertCount(4, $fields); $this->assertSame('testRight', $fields['testRight']->name); - } public function testMissingTypeAnnotation(): void @@ -409,7 +406,7 @@ public function testMissingTypeAnnotation(): void $queryProvider = $this->buildFieldsBuilder(); $this->expectException(MissingAnnotationException::class); - $queryProvider->getFields(new TestTypeMissingAnnotation(), true); + $queryProvider->getFields(new TestTypeMissingAnnotation()); } public function testSourceFieldDoesNotExists(): void @@ -417,8 +414,8 @@ public function testSourceFieldDoesNotExists(): void $queryProvider = $this->buildFieldsBuilder(); $this->expectException(FieldNotFoundException::class); - $this->expectExceptionMessage("There is an issue with a @SourceField annotation in class \"TheCodingMachine\GraphQLite\Fixtures\TestTypeMissingField\": Could not find a getter or a isser for field \"notExists\". Looked for: \"TheCodingMachine\GraphQLite\Fixtures\TestObject::notExists()\", \"TheCodingMachine\GraphQLite\Fixtures\TestObject::getNotExists()\", \"TheCodingMachine\GraphQLite\Fixtures\TestObject::isNotExists()"); - $queryProvider->getFields(new TestTypeMissingField(), true); + $this->expectExceptionMessage('There is an issue with a @SourceField annotation in class "TheCodingMachine\GraphQLite\Fixtures\TestTypeMissingField": Could not find a getter or a isser for field "notExists". Looked for: "TheCodingMachine\GraphQLite\Fixtures\TestObject::notExists()", "TheCodingMachine\GraphQLite\Fixtures\TestObject::getNotExists()", "TheCodingMachine\GraphQLite\Fixtures\TestObject::isNotExists()'); + $queryProvider->getFields(new TestTypeMissingField()); } public function testSourceFieldHasMissingReturnType(): void @@ -427,13 +424,13 @@ public function testSourceFieldHasMissingReturnType(): void $this->expectException(CannotMapTypeException::class); $this->expectExceptionMessage('For return type of TheCodingMachine\GraphQLite\Fixtures\TestObjectMissingReturnType::getTest, a type-hint is missing (or PHPDoc specifies a "mixed" type-hint). Please provide a better type-hint.'); - $queryProvider->getFields(new TestTypeMissingReturnType(), true); + $queryProvider->getFields(new TestTypeMissingReturnType()); } public function testSourceFieldIsId(): void { $queryProvider = $this->buildFieldsBuilder(); - $fields = $queryProvider->getFields(new TestTypeId(), true); + $fields = $queryProvider->getFields(new TestTypeId()); $this->assertCount(1, $fields); $this->assertSame('test', $fields['test']->name); @@ -454,15 +451,14 @@ public function testFromSourceFieldsInterface(): void $this->getParameterMiddlewarePipe(), new AuthorizationFieldMiddleware( new VoidAuthenticationService(), - new VoidAuthorizationService() + new VoidAuthorizationService(), ), - new InputFieldMiddlewarePipe() + new InputFieldMiddlewarePipe(), ); - $fields = $queryProvider->getFields(new TestTypeWithSourceFieldInterface(), true); + $fields = $queryProvider->getFields(new TestTypeWithSourceFieldInterface()); $this->assertCount(1, $fields); $this->assertSame('test', $fields['test']->name); - } public function testQueryProviderWithIterableClass(): void @@ -571,7 +567,7 @@ public function testQueryProviderWithInvalidInputType(): void $queryProvider = $this->buildFieldsBuilder(); $this->expectException(CannotMapTypeException::class); - $this->expectExceptionMessage('For parameter $foo, in TheCodingMachine\GraphQLite\Fixtures\TestControllerWithInvalidInputType::test, cannot map class "Exception" to a known GraphQL input type. Are you missing a @Factory annotation? If you have a @Factory annotation, is it in a namespace analyzed by GraphQLite?'); + $this->expectExceptionMessage('For parameter $foo, in TheCodingMachine\GraphQLite\Fixtures\TestControllerWithInvalidInputType::test, cannot map class "Throwable" to a known GraphQL input type. Are you missing a @Factory annotation? If you have a @Factory annotation, is it in a namespace analyzed by GraphQLite?'); $queryProvider->getQueries($controller); } @@ -657,14 +653,13 @@ public function testSourceFieldWithFailWith(): void $queryProvider = $this->buildFieldsBuilder(); - $fields = $queryProvider->getFields($controller, true); + $fields = $queryProvider->getFields($controller); $this->assertCount(1, $fields); $this->assertSame('test', $fields['test']->name); $this->assertInstanceOf(StringType::class, $fields['test']->getType()); - $resolve = $fields['test']->resolveFn; $result = $resolve(new stdClass(), [], null, $this->createMock(ResolveInfo::class)); @@ -678,7 +673,7 @@ public function testSourceFieldBadOutputTypeException(): void $queryProvider = $this->buildFieldsBuilder(); $this->expectException(CannotMapTypeExceptionInterface::class); $this->expectExceptionMessage('For @SourceField "test" declared in "TheCodingMachine\GraphQLite\Fixtures\TestSourceFieldBadOutputType", cannot find GraphQL type "[NotExists]". Check your TypeMapper configuration.'); - $queryProvider->getFields(new TestSourceFieldBadOutputType(), true); + $queryProvider->getFields(new TestSourceFieldBadOutputType()); } public function testSourceFieldBadOutputType2Exception(): void @@ -686,7 +681,7 @@ public function testSourceFieldBadOutputType2Exception(): void $queryProvider = $this->buildFieldsBuilder(); $this->expectException(CannotMapTypeExceptionInterface::class); $this->expectExceptionMessage('For @SourceField "test" declared in "TheCodingMachine\GraphQLite\Fixtures\TestSourceFieldBadOutputType2", Syntax Error: Expected ], found '); - $queryProvider->getFields(new TestSourceFieldBadOutputType2(), true); + $queryProvider->getFields(new TestSourceFieldBadOutputType2()); } public function testBadOutputTypeException(): void @@ -694,7 +689,7 @@ public function testBadOutputTypeException(): void $queryProvider = $this->buildFieldsBuilder(); $this->expectException(CannotMapTypeExceptionInterface::class); $this->expectExceptionMessage('For return type of TheCodingMachine\GraphQLite\Fixtures\TestFieldBadOutputType::test, cannot find GraphQL type "[NotExists]". Check your TypeMapper configuration.'); - $queryProvider->getFields(new TestFieldBadOutputType(), true); + $queryProvider->getFields(new TestFieldBadOutputType()); } public function testBadInputTypeException(): void @@ -702,7 +697,7 @@ public function testBadInputTypeException(): void $queryProvider = $this->buildFieldsBuilder(); $this->expectException(CannotMapTypeExceptionInterface::class); $this->expectExceptionMessage('For parameter $input, in TheCodingMachine\GraphQLite\Fixtures\TestFieldBadInputType::testInput, cannot find GraphQL type "[NotExists]". Check your TypeMapper configuration.'); - $queryProvider->getFields(new TestFieldBadInputType(), true); + $queryProvider->getFields(new TestFieldBadInputType()); } public function testDoubleReturnException(): void @@ -710,7 +705,7 @@ public function testDoubleReturnException(): void $queryProvider = $this->buildFieldsBuilder(); $this->expectException(InvalidDocBlockRuntimeException::class); $this->expectExceptionMessage('Method TheCodingMachine\\GraphQLite\\Fixtures\\TestDoubleReturnTag::test has several @return annotations.'); - $queryProvider->getFields(new TestDoubleReturnTag(), true); + $queryProvider->getFields(new TestDoubleReturnTag()); } public function testMissingArgument(): void @@ -855,17 +850,6 @@ public function testQueryProviderWithUnionInputParam(): void $queries = $queryProvider->getQueries($controller); } - public function testParameterAnnotationOnNonExistingParameter(): void - { - $controller = new TestControllerWithInvalidParameterAnnotation(); - - $queryProvider = $this->buildFieldsBuilder(); - - $this->expectException(InvalidParameterException::class); - $this->expectExceptionMessage('Parameter "id" declared in annotation "TheCodingMachine\\GraphQLite\\Annotations\\HideParameter" of method "TheCodingMachine\\GraphQLite\\Fixtures\\TestControllerWithInvalidParameterAnnotation::test()" does not exist.'); - $queries = $queryProvider->getQueries($controller); - } - public function testParameterAnnotationOnNonExistingParameterInSourceField(): void { $controller = new TestTypeWithSourceFieldInvalidParameterAnnotation(); @@ -873,7 +857,7 @@ public function testParameterAnnotationOnNonExistingParameterInSourceField(): vo $queryProvider = $this->buildFieldsBuilder(); $this->expectException(InvalidParameterException::class); - $this->expectExceptionMessage('Could not find parameter "foo" declared in annotation "TheCodingMachine\\GraphQLite\\Annotations\\HideParameter". This annotation is itself declared in a SourceField annotation targeting resolver "TheCodingMachine\\GraphQLite\\Fixtures\\TestObject::getSibling()".'); + $this->expectExceptionMessage('Could not find parameter "foo" declared in annotation "TheCodingMachine\\GraphQLite\\Annotations\\HideParameter". This annotation is itself declared in a SourceField attribute targeting resolver "TheCodingMachine\\GraphQLite\\Fixtures\\TestObject::getSibling()".'); $fields = $queryProvider->getFields($controller); } diff --git a/tests/Fixtures/AbstractTestController.php b/tests/Fixtures/AbstractTestController.php index deb15c2d81..f4042a198b 100644 --- a/tests/Fixtures/AbstractTestController.php +++ b/tests/Fixtures/AbstractTestController.php @@ -8,9 +8,7 @@ // An abstract class to test that the GlobControllerQueryProvider does not try anything with it. abstract class AbstractTestController { - /** - * @Query() - */ + #[Query] public function test(): string { return 'foo'; diff --git a/tests/Fixtures/AnnotatedInterfaces/Controllers/AnnotatedInterfaceController.php b/tests/Fixtures/AnnotatedInterfaces/Controllers/AnnotatedInterfaceController.php index f93611fe16..4b0970ba1e 100644 --- a/tests/Fixtures/AnnotatedInterfaces/Controllers/AnnotatedInterfaceController.php +++ b/tests/Fixtures/AnnotatedInterfaces/Controllers/AnnotatedInterfaceController.php @@ -15,17 +15,13 @@ class AnnotatedInterfaceController { - /** - * @Query() - */ + #[Query] public function getClassA(): ClassA { return new ClassA(); } - /** - * @Query() - */ + #[Query] public function getFoo(): FooInterface { return new ClassD(); @@ -39,17 +35,13 @@ public function getFoo(): FooInterface return new ClassD(); }*/ - /** - * @Query() - */ + #[Query] public function getClassDAsWizInterface(): WizzInterface { return new ClassD(); } - /** - * @Query() - */ + #[Query] public function getQux(): QuxInterface { return new NotAnnotatedQux(); diff --git a/tests/Fixtures/AnnotatedInterfaces/Types/BarInterface.php b/tests/Fixtures/AnnotatedInterfaces/Types/BarInterface.php index b4f9ea3ccf..ade2792985 100644 --- a/tests/Fixtures/AnnotatedInterfaces/Types/BarInterface.php +++ b/tests/Fixtures/AnnotatedInterfaces/Types/BarInterface.php @@ -1,19 +1,15 @@ circularInputB = $circularInputB; } - /** - * @Field - */ - public function setBar(int $bar): void { + #[Field] + public function setBar(int $bar): void + { $this->bar = $bar; } - /** - * @return CircularInputB - */ - public function getCircularInputB() + public function getCircularInputB(): CircularInputB { return $this->circularInputB; } - /** - * @return int - */ public function getBar(): int { return $this->bar; diff --git a/tests/Fixtures/CircularInputReference/Types/CircularInputB.php b/tests/Fixtures/CircularInputReference/Types/CircularInputB.php index aaedad7775..eb5f4f793d 100644 --- a/tests/Fixtures/CircularInputReference/Types/CircularInputB.php +++ b/tests/Fixtures/CircularInputReference/Types/CircularInputB.php @@ -1,48 +1,36 @@ circularInputA = $circularInputA; } - /** - * @Field - */ - public function setBar(int $bar): void { + #[Field] + public function setBar(int $bar): void + { $this->bar = $bar; } - /** - * @return CircularInputA - */ public function getCircularInputA() { return $this->circularInputA; } - /** - * @return int - */ public function getBar(): int { return $this->bar; diff --git a/tests/Fixtures/DuplicateInputTypes/TestFactory.php b/tests/Fixtures/DuplicateInputTypes/TestFactory.php index 6fe81dd275..5072bd8288 100644 --- a/tests/Fixtures/DuplicateInputTypes/TestFactory.php +++ b/tests/Fixtures/DuplicateInputTypes/TestFactory.php @@ -1,20 +1,15 @@ value = $value; } - /** - * @Field() - */ + #[Field] public function getValue(): string { return $this->value; } - /** - * @Factory(name="InAndOut") - */ + #[Factory(name: 'InAndOut')] public static function create(string $value): self { return new self($value); diff --git a/tests/Fixtures/Inputs/FooBar.php b/tests/Fixtures/Inputs/FooBar.php index ff76408377..56fa07db42 100644 --- a/tests/Fixtures/Inputs/FooBar.php +++ b/tests/Fixtures/Inputs/FooBar.php @@ -1,46 +1,32 @@ foo = $foo; $this->bar = $bar; diff --git a/tests/Fixtures/Inputs/InputInterface.php b/tests/Fixtures/Inputs/InputInterface.php index 744299c5f5..f307cf916c 100644 --- a/tests/Fixtures/Inputs/InputInterface.php +++ b/tests/Fixtures/Inputs/InputInterface.php @@ -1,12 +1,12 @@ foo = $foo; } - /** - * @Field(for="InputWithSetterInput") - * @Field(for="ForcedTypeInput", inputType="Int!") - */ - public function setBar(int $bar): void { + #[Field(for: 'InputWithSetterInput')] + #[Field(for: 'ForcedTypeInput', inputType: 'Int!')] + public function setBar(int $bar): void + { $this->bar = $bar; } - /** - * @return string - */ public function getFoo(): string { return $this->foo; } - /** - * @return int - */ public function getBar(): int { return $this->bar; diff --git a/tests/Fixtures/Inputs/TestConstructorAndProperties.php b/tests/Fixtures/Inputs/TestConstructorAndProperties.php index 82647c91ad..57243d4926 100644 --- a/tests/Fixtures/Inputs/TestConstructorAndProperties.php +++ b/tests/Fixtures/Inputs/TestConstructorAndProperties.php @@ -1,49 +1,40 @@ date = $date; $this->foo = $foo; } - public function getDate(): \DateTimeImmutable + public function getDate(): DateTimeImmutable { return $this->date; } public function setFoo(string $foo): void { - throw new \RuntimeException("This should not be called"); + throw new RuntimeException('This should not be called'); } public function getFoo(): string diff --git a/tests/Fixtures/Inputs/TestOnlyConstruct.php b/tests/Fixtures/Inputs/TestOnlyConstruct.php index d4a0ecbf83..e01be70029 100644 --- a/tests/Fixtures/Inputs/TestOnlyConstruct.php +++ b/tests/Fixtures/Inputs/TestOnlyConstruct.php @@ -1,36 +1,26 @@ foo = $foo; $this->bar = $bar; @@ -46,7 +36,7 @@ public function setBar(int $bar): void { throw new Exception('This should not be called!'); } - + public function setBaz(bool $baz): void { throw new Exception('This should not be called!'); diff --git a/tests/Fixtures/Inputs/TypedFooBar.php b/tests/Fixtures/Inputs/TypedFooBar.php index 770eb24fd9..d04e92c481 100644 --- a/tests/Fixtures/Inputs/TypedFooBar.php +++ b/tests/Fixtures/Inputs/TypedFooBar.php @@ -1,23 +1,18 @@ new Contact('Joe'), 'Bill' => new Contact('Bill'), default => null, @@ -43,16 +42,15 @@ public function saveContact(Contact $contact): Contact } #[Mutation] - public function saveBirthDate(\DateTimeInterface $birthDate): Contact { + public function saveBirthDate(DateTimeInterface $birthDate): Contact + { $contact = new Contact('Bill'); $contact->setBirthDate($birthDate); return $contact; } - /** - * @return Contact[] - */ + /** @return Contact[] */ #[Query] public function getContactsIterator(): ArrayResult { @@ -62,9 +60,7 @@ public function getContactsIterator(): ArrayResult ]); } - /** - * @return string[]|ArrayResult - */ + /** @return string[]|ArrayResult */ #[Query] public function getContactsNamesIterator(): ArrayResult { @@ -86,7 +82,7 @@ public function getOtherContact(): Contact * @return Result|Contact[]|null */ #[Query] - public function getNullableResult(): ?Result + public function getNullableResult(): Result|null { return null; } diff --git a/tests/Fixtures/Integration/Controllers/FilterController.php b/tests/Fixtures/Integration/Controllers/FilterController.php index 9c027cf6ad..abb7ee2e57 100644 --- a/tests/Fixtures/Integration/Controllers/FilterController.php +++ b/tests/Fixtures/Integration/Controllers/FilterController.php @@ -1,30 +1,29 @@ getValues()); + return array_map(static function ($item) { + return (string) $item; + }, $filter->getValues()); } - /** - * @Query() - * @return string[]|null - */ - public function echoNullableFilters(?Filter $filter): ?array + /** @return string[]|null */ + #[Query] + public function echoNullableFilters(Filter|null $filter): array|null { if ($filter === null) { return null; @@ -32,10 +31,7 @@ public function echoNullableFilters(?Filter $filter): ?array return $this->echoFilters($filter); } - /** - * @Query() - * @return string - */ + #[Query] public function echoResolveInfo(ResolveInfo $info): string { return $info->fieldName; diff --git a/tests/Fixtures/Integration/Controllers/PostController.php b/tests/Fixtures/Integration/Controllers/PostController.php index 1390069187..8df4ae2179 100644 --- a/tests/Fixtures/Integration/Controllers/PostController.php +++ b/tests/Fixtures/Integration/Controllers/PostController.php @@ -10,26 +10,28 @@ class PostController { /** - * @Mutation() * @param Post $post * * @return Post */ + #[Mutation] public function createPost(Post $post): Post { return $post; } /** - * @Mutation() - * @UseInputType(for="$post", inputType="UpdatePostInput") * * @param int $id * @param Post $post * * @return Post */ - public function updatePost(int $id, Post $post): Post + #[Mutation] + public function updatePost( + int $id, + #[UseInputType('UpdatePostInput')] + Post $post): Post { $post->id = $id; diff --git a/tests/Fixtures/Integration/Controllers/PreferencesController.php b/tests/Fixtures/Integration/Controllers/PreferencesController.php index a31f21900b..599ec12b4c 100644 --- a/tests/Fixtures/Integration/Controllers/PreferencesController.php +++ b/tests/Fixtures/Integration/Controllers/PreferencesController.php @@ -8,11 +8,11 @@ class PreferencesController { /** - * @Mutation() * @param Preferences $preferences * * @return Preferences */ + #[Mutation] public function updatePreferences(Preferences $preferences): Preferences { return $preferences; diff --git a/tests/Fixtures/Integration/Controllers/ProductController.php b/tests/Fixtures/Integration/Controllers/ProductController.php index 75779b10ca..65b1c42408 100644 --- a/tests/Fixtures/Integration/Controllers/ProductController.php +++ b/tests/Fixtures/Integration/Controllers/ProductController.php @@ -1,16 +1,15 @@ setName("Special","box"); + $product->setName('Special', 'box'); $product->price = 11.99; $product->multi = 11.11; return $product; } - /** - * @Mutation() - * @UseInputType(for="$product", inputType="UpdateTrickyProductInput!") - * - * @param TrickyProduct $product - * @return TrickyProduct - */ - public function updateTrickyProduct(TrickyProduct $product): TrickyProduct + #[Mutation] + public function updateTrickyProduct( + #[UseInputType('UpdateTrickyProductInput!')] + TrickyProduct $product, + ): TrickyProduct { return $product; } diff --git a/tests/Fixtures/Integration/Controllers/SecurityController.php b/tests/Fixtures/Integration/Controllers/SecurityController.php index 27c09b7b03..47c9dc294e 100644 --- a/tests/Fixtures/Integration/Controllers/SecurityController.php +++ b/tests/Fixtures/Integration/Controllers/SecurityController.php @@ -1,82 +1,65 @@ bar; } diff --git a/tests/Fixtures/Integration/Models/Article.php b/tests/Fixtures/Integration/Models/Article.php index 0cf0f3a80d..49d914bc0e 100644 --- a/tests/Fixtures/Integration/Models/Article.php +++ b/tests/Fixtures/Integration/Models/Article.php @@ -6,22 +6,15 @@ use TheCodingMachine\GraphQLite\Annotations\Input; use TheCodingMachine\GraphQLite\Annotations\Type; -/** - * @Type() - * @Input() - */ + +#[Type, Input] class Article extends Post { - /** - * @Field(for="Article") - * @var int - */ - public $id = 2; - /** - * @Field() - * @var string|null - */ - public $magazine = null; + #[Field(for: "Article")] + public int $id = 2; + + #[Field] + public ?string $magazine = null; } diff --git a/tests/Fixtures/Integration/Models/Button.php b/tests/Fixtures/Integration/Models/Button.php index 4685ac00a3..0e1a024caa 100644 --- a/tests/Fixtures/Integration/Models/Button.php +++ b/tests/Fixtures/Integration/Models/Button.php @@ -7,52 +7,26 @@ use TheCodingMachine\GraphQLite\Annotations\Field; use TheCodingMachine\GraphQLite\Annotations\Type; -/** - * @Type - */ +#[Type] class Button { - /** - * @var Color - */ - private $color; - - /** - * @var Size - */ - private $size; - - /** - * @var Position - */ - private $state; - - public function __construct(Color $color, Size $size, Position $state) + public function __construct(private Color $color, private Size $size, private Position $state) { - $this->color = $color; - $this->size = $size; - $this->state = $state; } - /** - * @Field - */ + #[Field] public function getColor(): Color { return $this->color; } - /** - * @Field - */ + #[Field] public function getSize(): Size { return $this->size; } - /** - * @Field - */ + #[Field] public function getState(): Position { return $this->state; diff --git a/tests/Fixtures/Integration/Models/Contact.php b/tests/Fixtures/Integration/Models/Contact.php index 58d12549dd..6fdc1f344d 100644 --- a/tests/Fixtures/Integration/Models/Contact.php +++ b/tests/Fixtures/Integration/Models/Contact.php @@ -1,122 +1,72 @@ name = $name; } public function getName() @@ -124,70 +74,49 @@ public function getName() return $this->name; } - /** - * @deprecated use field `name` - */ + /** @deprecated use field `name` */ public function getDeprecatedName() { return $this->name; } - public function getManager(): ?Contact + public function getManager(): Contact|null { return $this->manager; } - /** - * @param Contact|null $manager - */ - public function setManager(?Contact $manager): void + public function setManager(Contact|null $manager): void { $this->manager = $manager; } - /** - * @return Contact[] - */ + /** @return Contact[] */ public function getRelations(): array { return $this->relations; } - /** - * @param Contact[] $relations - */ + /** @param Contact[] $relations */ public function setRelations(array $relations): void { $this->relations = $relations; } - /** - * @return UploadedFileInterface - */ public function getPhoto(): UploadedFileInterface { return $this->photo; } - /** - * @param UploadedFileInterface $photo - */ public function setPhoto(UploadedFileInterface $photo): void { $this->photo = $photo; } - /** - * @return DateTimeInterface - */ - public function getBirthDate() + public function getBirthDate(): DateTimeInterface { return $this->birthDate; } - /** - * @param DateTimeInterface $birthDate - */ public function setBirthDate(DateTimeInterface $birthDate): void { $this->birthDate = $birthDate; @@ -195,31 +124,25 @@ public function setBirthDate(DateTimeInterface $birthDate): void /** * This getter will be overridden in the extend class. - * - * @Field() - * @return string */ + #[Field] public function getCompany(): string { return $this->company; } - /** - * @param string $company - */ public function setCompany(string $company): void { $this->company = $company; } - /** - * @Field() - */ - public function repeatInnerName(#[Prefetch('prefetchTheContacts')] $data): string + #[Field] + public function repeatInnerName(#[Prefetch('prefetchTheContacts')] + $data,): string { $index = array_search($this, $data, true); if ($index === false) { - throw new \RuntimeException('Index not found'); + throw new RuntimeException('Index not found'); } return $data[$index]->getName(); } @@ -229,76 +152,60 @@ public static function prefetchTheContacts(iterable $contacts) return $contacts; } - /** - * @Field() - * @Logged() - * @return string - */ + #[Field] + + #[Logged] public function onlyLogged(): string { return 'you can see this only if you are logged'; } - /** - * @Field() - * @Right(name="CAN_SEE_SECRET") - * @return string - */ + #[Field] + + #[Right('CAN_SEE_SECRET')] public function secret(): string { return 'you can see this only if you have the good right'; } - /** - * @return int - */ public function getAge(): int { return $this->age; } - /** - * @return string - */ public function getStatus(): string { return 'bar'; } - /** - * @return string - */ public function getZipcode(string $foo): string { return $this->zipcode; } - /** - * @return string - */ private function getAddress(): string { return $this->address; } - /** - * @Field() - * @Autowire(for="testService", identifier="testService") - * @Autowire(for="$otherTestService") - * @return string - */ - public function injectService(string $testService, stdClass $otherTestService = null): string + #[Field] + public function injectService( + #[Autowire(identifier: 'testService')] + string $testService, + #[Autowire] + stdClass|null $otherTestService = null, + ): string { if ($testService !== 'foo') { return 'KO'; } - if (!$otherTestService instanceof stdClass) { + if (! $otherTestService instanceof stdClass) { return 'KO'; } return 'OK'; } - public function injectServiceFromExternal(string $testService, string $testSkip = "foo", string $id = '42'): string + public function injectServiceFromExternal(string $testService, string $testSkip = 'foo', string $id = '42'): string { if ($testService !== 'foo') { return 'KO'; diff --git a/tests/Fixtures/Integration/Models/Filter.php b/tests/Fixtures/Integration/Models/Filter.php index e482c7e3fe..0acf5dab20 100644 --- a/tests/Fixtures/Integration/Models/Filter.php +++ b/tests/Fixtures/Integration/Models/Filter.php @@ -34,8 +34,8 @@ public function mergeValues(array $values): void /** * @param string[] $values * - * @Factory() */ + #[Factory] public static function create(array $values = []): self { return new self($values); diff --git a/tests/Fixtures/Integration/Models/Post.php b/tests/Fixtures/Integration/Models/Post.php index ae91b13ae0..758931f447 100644 --- a/tests/Fixtures/Integration/Models/Post.php +++ b/tests/Fixtures/Integration/Models/Post.php @@ -1,5 +1,7 @@ title = $title; $this->description = 'bar'; } - /** - * @return string|null - */ - public function getDescription(): ?string + public function getDescription(): string|null { return $this->description; } - /** - * @param string|null $description - */ - public function setDescription(?string $description): void + public function setDescription(string|null $description): void { $this->description = $description; } - /** - * @param string|null $summary - */ - public function setSummary(?string $summary): void + public function setSummary(string|null $summary): void { $this->summary = $summary; } - /** - * @param string $inaccessible - */ private function setInaccessible(string $inaccessible): void { $this->inaccessible = $inaccessible; diff --git a/tests/Fixtures/Integration/Models/Preferences.php b/tests/Fixtures/Integration/Models/Preferences.php index 9f37a247cd..499190a235 100644 --- a/tests/Fixtures/Integration/Models/Preferences.php +++ b/tests/Fixtures/Integration/Models/Preferences.php @@ -7,35 +7,23 @@ use TheCodingMachine\GraphQLite\Annotations\Input; use TheCodingMachine\GraphQLite\Annotations\Type; -/** - * @Type() - * @Input() - */ +#[Type, Input] class Preferences { - /** - * @Field(inputType="Int!") - * @var int - */ - private $id; + #[Field(inputType: "Int!")] + private int $id; /** - * @Field(inputType="[String!]!") * @var string[] */ - private $options; + #[Field(inputType: "[String!]!")] + private array $options; - /** - * @Field(inputType="Boolean!") - * @var bool - */ - private $enabled; + #[Field(inputType: "Boolean!")] + private bool $enabled; - /** - * @Field(inputType="String!") - * @var string - */ - private $name; + #[Field(inputType: "String!")] + private string $name; public function __construct(int $id, array $options, bool $enabled, string $name) { diff --git a/tests/Fixtures/Integration/Models/Product.php b/tests/Fixtures/Integration/Models/Product.php index e9479c3d90..ee98fc610f 100644 --- a/tests/Fixtures/Integration/Models/Product.php +++ b/tests/Fixtures/Integration/Models/Product.php @@ -1,11 +1,9 @@ name = $name; - $this->price = $price; - $this->type = $type; } - /** - * @Field(name="name") - * @return string - */ + #[Field(name: 'name')] public function getName(): string { return $this->name; } - /** - * @Field() - * @return float - */ + #[Field] public function getPrice(): float { return $this->price; } - /** - * @Field() - * @Right("YOU_DONT_HAVE_THIS_RIGHT") - * @FailWith(null) - * @return string - */ + #[Field] + #[Right('YOU_DONT_HAVE_THIS_RIGHT')] + #[FailWith(null)] public function getUnauthorized(): string { return 'You are not allowed to see this'; } - /** - * @return ProductTypeEnum - */ public function getType(): ProductTypeEnum { return $this->type; } - /** - * @Factory() - * @return Product - */ - public static function create(string $name, float $price, ProductTypeEnum $type = null): self + #[Factory] + public static function create(string $name, float $price, ProductTypeEnum|null $type = null): self { return new self($name, $price, $type); } - /** - * @Field() - * @Security("this.isAllowed(secret)") - */ + #[Field] + #[Security(expression: 'this.isAllowed(secret)')] public function getMargin(string $secret): float { return 12.0; diff --git a/tests/Fixtures/Integration/Models/ProductTypeEnum.php b/tests/Fixtures/Integration/Models/ProductTypeEnum.php index 6bc1983700..57cd6f2d2e 100644 --- a/tests/Fixtures/Integration/Models/ProductTypeEnum.php +++ b/tests/Fixtures/Integration/Models/ProductTypeEnum.php @@ -4,10 +4,7 @@ use MyCLabs\Enum\Enum; use TheCodingMachine\GraphQLite\Annotations\EnumType; - -/** - * @EnumType(name="ProductTypes") - */ +#[EnumType(name: "ProductTypes")] class ProductTypeEnum extends Enum { const FOOD = 'food'; diff --git a/tests/Fixtures/Integration/Models/SpecialProduct.php b/tests/Fixtures/Integration/Models/SpecialProduct.php index 7c78ce908f..bf162ccc51 100644 --- a/tests/Fixtures/Integration/Models/SpecialProduct.php +++ b/tests/Fixtures/Integration/Models/SpecialProduct.php @@ -1,57 +1,34 @@ name = $name; - $this->price = $price; } - /** - * @Field() - * @return string - */ + #[Field] public function getSpecial(): string { - return "unicorn"; + return 'unicorn'; } - /** - * @Field() - * @return string - */ + #[Field] public function getName(): string { return $this->name; } - /** - * @Field() - * @return float - */ + #[Field] public function getPrice(): float { return $this->price; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Integration/Models/TrickyProduct.php b/tests/Fixtures/Integration/Models/TrickyProduct.php index 203cefda58..4003568168 100644 --- a/tests/Fixtures/Integration/Models/TrickyProduct.php +++ b/tests/Fixtures/Integration/Models/TrickyProduct.php @@ -1,123 +1,86 @@ name; } - /** - * @return float - */ public function getPrice(): float { return $this->price; } - /** - * @Field() - * @Autowire(for="testService", identifier="testService") - * @param string $name - * @param string $testService - * @return void - */ - public function setName(string $name, string $testService): void + #[Field] + public function setName( + string $name, + #[Autowire(identifier: 'testService')] + string $testService, + ): void { - $this->name = $name . " " . $testService; + $this->name = $name . ' ' . $testService; } - /** - * @Field() - * @Right("CAN_SEE_SECRET") - * @return string - */ + #[Field] + + #[Right('CAN_SEE_SECRET')] public function getSecret(): string { return $this->secret; } - /** - * @Field() - * @Right("CAN_SET_SECRET") - * @param string $secret - */ + #[Field] + + #[Right('CAN_SEE_SECRET')] public function setSecret(string $secret): void { $this->secret = $secret; } - /** - * @Field() - * @Security("conditionalSecret == 'actually{secret}'") - * @Security("user && user.bar == 42") - * @param string $conditionalSecret - */ + #[Field] + #[Security("conditionalSecret == 'actually{secret}'")] + #[Security('user && user.bar == 42')] public function setConditionalSecret(string $conditionalSecret): void { $this->conditionalSecret = $conditionalSecret; } - /** - * @Field() - * @Security("this.isAllowed(key)") - */ + #[Field] + #[Security('this.isAllowed(key)')] public function getConditionalSecret(int $key): string { return $this->conditionalSecret; @@ -127,4 +90,4 @@ public function isAllowed(string $conditionalSecret): bool { return $conditionalSecret === '1234'; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Integration/Models/User.php b/tests/Fixtures/Integration/Models/User.php index f4ca921770..82bd2303be 100644 --- a/tests/Fixtures/Integration/Models/User.php +++ b/tests/Fixtures/Integration/Models/User.php @@ -1,31 +1,21 @@ email = $email; } - /** - * @Field(name="email") - * @return string - */ + #[Field] public function getEmail(): string { return $this->email; diff --git a/tests/Fixtures/Integration/Types/ContactFactory.php b/tests/Fixtures/Integration/Types/ContactFactory.php index 782394bdd4..e3802f503a 100644 --- a/tests/Fixtures/Integration/Types/ContactFactory.php +++ b/tests/Fixtures/Integration/Types/ContactFactory.php @@ -1,9 +1,9 @@ getName()); diff --git a/tests/Fixtures/Integration/Types/ContactType.php b/tests/Fixtures/Integration/Types/ContactType.php index 33ff5449f9..2881e50ccf 100644 --- a/tests/Fixtures/Integration/Types/ContactType.php +++ b/tests/Fixtures/Integration/Types/ContactType.php @@ -1,70 +1,65 @@ getName()); + return $prefix . ' ' . strtoupper($contact->getName()); } - /** - * @Field() - */ - public function repeatName(Contact $contact, #[Prefetch('prefetchContacts')] $data, string $suffix): string + #[Field] + public function repeatName(Contact $contact, #[Prefetch('prefetchContacts')] + $data, string $suffix,): string { $index = array_search($contact, $data['contacts'], true); if ($index === false) { - throw new \RuntimeException('Index not found'); + throw new RuntimeException('Index not found'); } - return $data['prefix'].$data['contacts'][$index]->getName().$suffix; + return $data['prefix'] . $data['contacts'][$index]->getName() . $suffix; } public static function prefetchContacts(iterable $contacts, string $prefix) { return [ 'contacts' => $contacts, - 'prefix' => $prefix + 'prefix' => $prefix, ]; } - /** - * - * @return Post[]|null - */ + /** @return Post[]|null */ #[Field] public function getPosts( Contact $contact, #[Prefetch('prefetchPosts')] - $posts - ): ?array { + $posts, + ): array|null { return $posts[$contact->getName()] ?? null; } @@ -74,10 +69,10 @@ public static function prefetchPosts(iterable $contacts): array foreach ($contacts as $contact) { $contactPost = array_filter( self::getContactPosts(), - fn(Post $post) => $post->author?->getName() === $contact->getName() + static fn (Post $post) => $post->author?->getName() === $contact->getName(), ); - if (!$contactPost) { + if (! $contactPost) { continue; } @@ -90,15 +85,15 @@ public static function prefetchPosts(iterable $contacts): array private static function getContactPosts(): array { return [ - self::generatePost('First Joe post', '1', new Contact('Joe')), - self::generatePost('First Bill post', '2', new Contact('Bill')), - self::generatePost('First Kate post', '3', new Contact('Kate')), + self::generatePost('First Joe post', 1, new Contact('Joe')), + self::generatePost('First Bill post', 2, new Contact('Bill')), + self::generatePost('First Kate post', 3, new Contact('Kate')), ]; } private static function generatePost( string $title, - string $id, + int $id, Contact $author, ): Post { $post = new Post($title); diff --git a/tests/Fixtures/Integration/Types/ExtendedContactOtherType.php b/tests/Fixtures/Integration/Types/ExtendedContactOtherType.php index 483689ffde..264f1babc5 100644 --- a/tests/Fixtures/Integration/Types/ExtendedContactOtherType.php +++ b/tests/Fixtures/Integration/Types/ExtendedContactOtherType.php @@ -1,27 +1,21 @@ getName()); } - /** - * @Field() - * @deprecated use field `uppercaseName` - */ + /** @deprecated use field `uppercaseName` */ + #[Field] public function deprecatedUppercaseName(Contact $contact): string { return strtoupper($contact->getName()); @@ -34,12 +28,10 @@ public function deprecatedUppercaseName(Contact $contact): string /** * Here, we are testing overriding the field in the extend class. - * - * @Field() - * @return string */ + #[Field] public function company(Contact $contact): string { - return $contact->getName().' Ltd'; + return $contact->getName() . ' Ltd'; } } diff --git a/tests/Fixtures/Integration/Types/FilterDecorator.php b/tests/Fixtures/Integration/Types/FilterDecorator.php index 305e6d2140..76cca38f7f 100644 --- a/tests/Fixtures/Integration/Types/FilterDecorator.php +++ b/tests/Fixtures/Integration/Types/FilterDecorator.php @@ -1,49 +1,37 @@ mergeValues($moreValues); return $filter; } - /** - * @Decorate(inputTypeName="FilterInput") - * @param Filter $filter - * @param int[] $evenMoreValues - * @return Filter - */ + /** @param int[] $evenMoreValues */ + #[Decorate(inputTypeName: 'FilterInput')] public static function staticDecorate(Filter $filter, array $evenMoreValues = []): Filter { $filter->mergeValues($evenMoreValues); return $filter; } - /** - * @Decorate(inputTypeName="FilterInput") - * @UseInputType(for="innerFilter", inputType="FilterInput") - * @param Filter $filter - * @param Filter|null $innerFilter - * @return Filter - */ - public static function recursiveDecorate(Filter $filter, ?Filter $innerFilter = null): Filter + #[Decorate(inputTypeName: 'FilterInput')] + public static function recursiveDecorate( + Filter $filter, + #[UseInputType(inputType: 'FilterInput')] + Filter|null $innerFilter = null, + ): Filter { return $filter; } diff --git a/tests/Fixtures/Interfaces/ClassA.php b/tests/Fixtures/Interfaces/ClassA.php index 951efa83ec..772174b646 100644 --- a/tests/Fixtures/Interfaces/ClassA.php +++ b/tests/Fixtures/Interfaces/ClassA.php @@ -1,22 +1,17 @@ foo = $foo; } public function getFoo(): string { return $this->foo; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Interfaces/ClassB.php b/tests/Fixtures/Interfaces/ClassB.php index ffe72d3fde..8371cb11d5 100644 --- a/tests/Fixtures/Interfaces/ClassB.php +++ b/tests/Fixtures/Interfaces/ClassB.php @@ -1,20 +1,14 @@ bar = $bar; } public function getBar(): string diff --git a/tests/Fixtures/Interfaces/Types/ClassAType.php b/tests/Fixtures/Interfaces/Types/ClassAType.php index 615ac51204..ea12b01533 100644 --- a/tests/Fixtures/Interfaces/Types/ClassAType.php +++ b/tests/Fixtures/Interfaces/Types/ClassAType.php @@ -1,5 +1,6 @@ getTest(); } - return new TestObject($string.$int.$str.($boolean?'true':'false').$float.$dateTimeImmutable->format('YmdHis').$dateTime->format('YmdHis').$withDefault.($id !== null ? $id->val() : '').$enum->getValue()); + return new TestObject($string . $int . $str . ($boolean ? 'true' : 'false') . $float . $dateTimeImmutable->format('YmdHis') . $dateTime->format('YmdHis') . $withDefault . ($id?->val() ?? '') . $enum->getValue()); } #[Query] @@ -50,7 +52,7 @@ public function testLogged(): TestObject } #[Query] - #[Right(name: "CAN_FOO")] + #[Right(name: 'CAN_FOO')] #[HideIfUnauthorized] public function testRight(): TestObject { @@ -69,47 +71,36 @@ public function testNameFromAnnotation(): TestObject return new TestObject('foo'); } - /** - * @return ArrayObject|TestObject[] - */ + /** @return ArrayObject|TestObject[] */ #[Query(name: 'arrayObject')] public function testArrayObject(): ArrayObject { return new ArrayObject([]); } - /** - * @return ArrayObject - */ + /** @return ArrayObject */ #[Query(name: 'arrayObjectGeneric')] public function testArrayObjectGeneric(): ArrayObject { return new ArrayObject([]); } - /** - * @return iterable|TestObject[] - */ + /** @return iterable|TestObject[] */ #[Query(name: 'iterable')] public function testIterable(): iterable { - return array(); + return []; } - /** - * @return iterable - */ + /** @return iterable */ #[Query(name: 'iterableGeneric')] public function testIterableGeneric(): iterable { - return array(); + return []; } - /** - * @return TestObject|TestObject2 - */ #[Query(name: 'union')] - public function testUnion() + public function testUnion(): TestObject|TestObject2 { return new TestObject2('foo'); } @@ -133,9 +124,11 @@ public function testReturn(TestObject $testObject): TestObject #[Subscription(outputType: 'ID')] public function testSubscribe(): void - {} + { + } #[Subscription(outputType: 'ID')] public function testSubscribeWithInput(TestObject $testObject): void - {} + { + } } diff --git a/tests/Fixtures/TestControllerNoReturnType.php b/tests/Fixtures/TestControllerNoReturnType.php index 1823c24453..9e9a3a4736 100644 --- a/tests/Fixtures/TestControllerNoReturnType.php +++ b/tests/Fixtures/TestControllerNoReturnType.php @@ -1,19 +1,14 @@ $params * @return array */ + #[Query] public function test(array $params): array { return $params; diff --git a/tests/Fixtures/TestControllerWithParamDateTime.php b/tests/Fixtures/TestControllerWithParamDateTime.php index c3cfb33fc7..6233a8f30a 100644 --- a/tests/Fixtures/TestControllerWithParamDateTime.php +++ b/tests/Fixtures/TestControllerWithParamDateTime.php @@ -1,5 +1,6 @@ test = $test; - $this->testBool = $testBool; } /** * This is a test summary - * @return string */ public function getTest(): string { return $this->test; } - /** - * @return bool - */ public function isTestBool(): bool { return $this->testBool; } - /** - * @return ?string - */ - public function testRight() + public function testRight(): string|null { - return "foo"; + return 'foo'; } public function getSibling(self $foo): self diff --git a/tests/Fixtures/TestObject2.php b/tests/Fixtures/TestObject2.php index 09ece4f89b..f0dd249db4 100644 --- a/tests/Fixtures/TestObject2.php +++ b/tests/Fixtures/TestObject2.php @@ -1,23 +1,15 @@ test2 = $test2; } - /** - * @return string - */ public function getTest2(): string { return $this->test2; diff --git a/tests/Fixtures/TestSelfType.php b/tests/Fixtures/TestSelfType.php index 5e967b89c0..2c725517b0 100644 --- a/tests/Fixtures/TestSelfType.php +++ b/tests/Fixtures/TestSelfType.php @@ -6,10 +6,8 @@ use TheCodingMachine\GraphQLite\Annotations\SourceField; use TheCodingMachine\GraphQLite\Annotations\Type; -/** - * @Type() - * @SourceField(name="test") - */ +#[Type] +#[SourceField(name: 'test')] class TestSelfType { private $foo = 'foo'; diff --git a/tests/Fixtures/TestSourceFieldBadOutputType.php b/tests/Fixtures/TestSourceFieldBadOutputType.php index 42b5cabc83..edf613f39d 100644 --- a/tests/Fixtures/TestSourceFieldBadOutputType.php +++ b/tests/Fixtures/TestSourceFieldBadOutputType.php @@ -1,15 +1,14 @@ foo = $foo; - $this->bar = $bar; } public function __get($name) diff --git a/tests/Fixtures/TestSourceNameType.php b/tests/Fixtures/TestSourceNameType.php index ddfbaa9a2f..2cf0b39009 100644 --- a/tests/Fixtures/TestSourceNameType.php +++ b/tests/Fixtures/TestSourceNameType.php @@ -1,17 +1,16 @@ getTest().$param; + return $test->getTest() . $param; } } diff --git a/tests/Fixtures/TestTypeId.php b/tests/Fixtures/TestTypeId.php index 626928afd3..74a7ae7257 100644 --- a/tests/Fixtures/TestTypeId.php +++ b/tests/Fixtures/TestTypeId.php @@ -1,15 +1,14 @@ getTest().$arg1; + return $test->getTest() . $arg1; } } diff --git a/tests/Fixtures/TestTypeWithFailWith.php b/tests/Fixtures/TestTypeWithFailWith.php index ad362e1f55..0d008c4d59 100644 --- a/tests/Fixtures/TestTypeWithFailWith.php +++ b/tests/Fixtures/TestTypeWithFailWith.php @@ -1,18 +1,16 @@ 'test']), + new SourceField(['name' => 'test']), ]; } } diff --git a/tests/Fixtures/TestTypeWithSourceFieldInvalidParameterAnnotation.php b/tests/Fixtures/TestTypeWithSourceFieldInvalidParameterAnnotation.php index 71dd3b33a2..2cc4b67c9a 100644 --- a/tests/Fixtures/TestTypeWithSourceFieldInvalidParameterAnnotation.php +++ b/tests/Fixtures/TestTypeWithSourceFieldInvalidParameterAnnotation.php @@ -1,16 +1,15 @@ getTest().$param; + return $test->getTest() . $param; } } diff --git a/tests/Fixtures/Types/FooExtendType.php b/tests/Fixtures/Types/FooExtendType.php index e462b90a1b..0ca423cf5b 100644 --- a/tests/Fixtures/Types/FooExtendType.php +++ b/tests/Fixtures/Types/FooExtendType.php @@ -1,23 +1,19 @@ getTest()); diff --git a/tests/Fixtures/Types/FooType.php b/tests/Fixtures/Types/FooType.php index 22b8c4d55b..c8ab91a854 100644 --- a/tests/Fixtures/Types/FooType.php +++ b/tests/Fixtures/Types/FooType.php @@ -3,13 +3,10 @@ namespace TheCodingMachine\GraphQLite\Fixtures\Types; -use TheCodingMachine\GraphQLite\Annotations\Right; -use TheCodingMachine\GraphQLite\Annotations\SourceField; use TheCodingMachine\GraphQLite\Annotations\Type; +use TheCodingMachine\GraphQLite\Fixtures\TestObject; -/** - * @Type(class=TheCodingMachine\GraphQLite\Fixtures\TestObject::class) - */ +#[Type(class: TestObject::class)] class FooType extends AbstractFooType { } diff --git a/tests/Fixtures/Types/TestFactory.php b/tests/Fixtures/Types/TestFactory.php index 77e22f97a9..5d619b9a9e 100644 --- a/tests/Fixtures/Types/TestFactory.php +++ b/tests/Fixtures/Types/TestFactory.php @@ -1,42 +1,37 @@ format('Y-m-d').'-'.implode('-', $stringList).'-'.count($dateList)); + return new TestObject2($date->format('Y-m-d') . '-' . implode('-', $stringList) . '-' . count($dateList)); } - /** - * @Decorate("InputObject") - */ + #[Decorate('InputObject')] public function myDecorator(TestObject $testObject, int $int): TestObject { return $testObject; diff --git a/tests/Integration/EndToEndTest.php b/tests/Integration/EndToEndTest.php index fccd26d937..ca9eb58033 100644 --- a/tests/Integration/EndToEndTest.php +++ b/tests/Integration/EndToEndTest.php @@ -4,7 +4,6 @@ namespace TheCodingMachine\GraphQLite\Integration; -use Doctrine\Common\Annotations\AnnotationReader as DoctrineAnnotationReader; use GraphQL\Error\DebugFlag; use GraphQL\Executor\ExecutionResult; use GraphQL\GraphQL; diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php index d363393d5f..06db73cce9 100644 --- a/tests/Integration/IntegrationTestCase.php +++ b/tests/Integration/IntegrationTestCase.php @@ -2,7 +2,6 @@ namespace TheCodingMachine\GraphQLite\Integration; -use Doctrine\Common\Annotations\AnnotationReader as DoctrineAnnotationReader; use GraphQL\Error\DebugFlag; use GraphQL\Executor\ExecutionResult; use Kcs\ClassFinder\Finder\ComposerFinder; @@ -282,7 +281,7 @@ public function createContainer(array $overloadedServices = []): ContainerInterf ); }, AnnotationReader::class => static function (ContainerInterface $container) { - return new AnnotationReader(new DoctrineAnnotationReader()); + return new AnnotationReader(); }, NamingStrategyInterface::class => static function () { return new NamingStrategy(); diff --git a/tests/Mappers/GlobTypeMapperTest.php b/tests/Mappers/GlobTypeMapperTest.php index ce8ab0aad4..e7e12053ae 100644 --- a/tests/Mappers/GlobTypeMapperTest.php +++ b/tests/Mappers/GlobTypeMapperTest.php @@ -1,13 +1,17 @@ function () { + FooType::class => static function () { return new FooType(); - } + }, ]); $typeGenerator = $this->getTypeGenerator(); @@ -43,7 +46,7 @@ public function testGlobTypeMapper(): void $cache = new Psr16Cache(new ArrayAdapter()); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), $cache); + $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); $this->assertSame([TestObject::class], $mapper->getSupportedClasses()); $this->assertTrue($mapper->canMapClassToType(TestObject::class)); @@ -53,25 +56,25 @@ public function testGlobTypeMapper(): void $this->assertFalse($mapper->canMapNameToType('NotExists')); // Again to test cache - $anotherMapperSameCache = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), $cache); + $anotherMapperSameCache = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); $this->assertTrue($anotherMapperSameCache->canMapClassToType(TestObject::class)); $this->assertTrue($anotherMapperSameCache->canMapNameToType('Foo')); $this->expectException(CannotMapTypeException::class); - $mapper->mapClassToType(\stdClass::class, null); + $mapper->mapClassToType(stdClass::class, null); } public function testGlobTypeMapperDuplicateTypesException(): void { $container = new LazyContainer([ - TestType::class => function () { + TestType::class => static function () { return new TestType(); - } + }, ]); $typeGenerator = $this->getTypeGenerator(); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\DuplicateTypes'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), new Psr16Cache(new NullAdapter())); + $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\DuplicateTypes'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), new Psr16Cache(new NullAdapter())); $this->expectException(DuplicateMappingException::class); $mapper->canMapClassToType(TestType::class); @@ -80,14 +83,14 @@ public function testGlobTypeMapperDuplicateTypesException(): void public function testGlobTypeMapperDuplicateInputsException(): void { $container = new LazyContainer([ - TestInput::class => function () { + TestInput::class => static function () { return new TestInput(); - } + }, ]); $typeGenerator = $this->getTypeGenerator(); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\DuplicateInputs'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), new Psr16Cache(new NullAdapter())); + $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\DuplicateInputs'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), new Psr16Cache(new NullAdapter())); $this->expectException(DuplicateMappingException::class); $mapper->canMapClassToInputType(TestInput::class); @@ -103,18 +106,20 @@ public function testGlobTypeMapperDuplicateInputTypesException(): void $typeGenerator = $this->getTypeGenerator(); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\DuplicateInputTypes'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), new Psr16Cache(new NullAdapter())); + $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\DuplicateInputTypes'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), new Psr16Cache(new NullAdapter())); $caught = false; try { $mapper->canMapClassToInputType(TestObject::class); } catch (DuplicateMappingException $e) { // Depending on the environment, one of the messages can be returned. - $this->assertContains($e->getMessage(), + $this->assertContains( + $e->getMessage(), [ 'The class \'TheCodingMachine\GraphQLite\Fixtures\TestObject\' should be mapped to only one GraphQL Input type. Two methods are pointing via the @Factory annotation to this class: \'TheCodingMachine\GraphQLite\Fixtures\DuplicateInputTypes\TestFactory::myFactory\' and \'TheCodingMachine\GraphQLite\Fixtures\DuplicateInputTypes\TestFactory2::myFactory\'', - 'The class \'TheCodingMachine\GraphQLite\Fixtures\TestObject\' should be mapped to only one GraphQL Input type. Two methods are pointing via the @Factory annotation to this class: \'TheCodingMachine\GraphQLite\Fixtures\DuplicateInputTypes\TestFactory2::myFactory\' and \'TheCodingMachine\GraphQLite\Fixtures\DuplicateInputTypes\TestFactory::myFactory\'' - ]); + 'The class \'TheCodingMachine\GraphQLite\Fixtures\TestObject\' should be mapped to only one GraphQL Input type. Two methods are pointing via the @Factory annotation to this class: \'TheCodingMachine\GraphQLite\Fixtures\DuplicateInputTypes\TestFactory2::myFactory\' and \'TheCodingMachine\GraphQLite\Fixtures\DuplicateInputTypes\TestFactory::myFactory\'', + ], + ); $caught = true; } $this->assertTrue($caught, 'DuplicateMappingException is thrown'); @@ -123,14 +128,14 @@ public function testGlobTypeMapperDuplicateInputTypesException(): void public function testGlobTypeMapperInheritedInputTypesException(): void { $container = new LazyContainer([ - ChildTestFactory::class => function() { + ChildTestFactory::class => static function () { return new ChildTestFactory(); - } + }, ]); $typeGenerator = $this->getTypeGenerator(); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\InheritedInputTypes'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), new Psr16Cache(new NullAdapter())); + $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\InheritedInputTypes'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), new Psr16Cache(new NullAdapter())); //$this->expectException(DuplicateMappingException::class); //$this->expectExceptionMessage('The class \'TheCodingMachine\GraphQLite\Fixtures\TestObject\' should be mapped to only one GraphQL Input type. Two methods are pointing via the @Factory annotation to this class: \'TheCodingMachine\GraphQLite\Fixtures\DuplicateInputTypes\TestFactory::myFactory\' and \'TheCodingMachine\GraphQLite\Fixtures\DuplicateInputTypes\TestFactory2::myFactory\''); @@ -141,31 +146,31 @@ public function testGlobTypeMapperInheritedInputTypesException(): void public function testGlobTypeMapperClassNotFoundException(): void { $container = new LazyContainer([ - TestType::class => function () { + TestType::class => static function () { return new TestType(); - } + }, ]); $typeGenerator = $this->getTypeGenerator(); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\BadClassType'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), new Psr16Cache(new NullAdapter())); + $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\BadClassType'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), new Psr16Cache(new NullAdapter())); $this->expectException(ClassNotFoundException::class); - $this->expectExceptionMessage("Could not autoload class 'Foobar' defined in @Type annotation of class 'TheCodingMachine\\GraphQLite\\Fixtures\\BadClassType\\TestType'"); + $this->expectExceptionMessage("Could not autoload class 'Foobar' defined in #[Type] attribute of class 'TheCodingMachine\\GraphQLite\\Fixtures\\BadClassType\\TestType'"); $mapper->canMapClassToType(TestType::class); } public function testGlobTypeMapperNameNotFoundException(): void { $container = new LazyContainer([ - FooType::class => function () { + FooType::class => static function () { return new FooType(); - } + }, ]); $typeGenerator = $this->getTypeGenerator(); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), new Psr16Cache(new NullAdapter())); + $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), new Psr16Cache(new NullAdapter())); $this->expectException(CannotMapTypeException::class); $mapper->mapNameToType('NotExists', $this->getTypeMapper()); @@ -174,19 +179,19 @@ public function testGlobTypeMapperNameNotFoundException(): void public function testGlobTypeMapperInputType(): void { $container = new LazyContainer([ - FooType::class => function () { + FooType::class => static function () { return new FooType(); }, - TestFactory::class => function () { + TestFactory::class => static function () { return new TestFactory(); - } + }, ]); $typeGenerator = $this->getTypeGenerator(); $cache = new Psr16Cache(new ArrayAdapter()); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), $cache); + $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); $this->assertTrue($mapper->canMapClassToInputType(TestObject::class)); @@ -195,12 +200,11 @@ public function testGlobTypeMapperInputType(): void $this->assertSame('TestObjectInput', $inputType->name); // Again to test cache - $anotherMapperSameCache = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), $cache); + $anotherMapperSameCache = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); $this->assertTrue($anotherMapperSameCache->canMapClassToInputType(TestObject::class)); $this->assertSame('TestObjectInput', $anotherMapperSameCache->mapClassToInputType(TestObject::class, $this->getTypeMapper())->name); - $this->expectException(CannotMapTypeException::class); $mapper->mapClassToInputType(TestType::class, $this->getTypeMapper()); } @@ -208,12 +212,12 @@ public function testGlobTypeMapperInputType(): void public function testGlobTypeMapperExtend(): void { $container = new LazyContainer([ - FooType::class => function () { + FooType::class => static function () { return new FooType(); }, - FooExtendType::class => function () { + FooExtendType::class => static function () { return new FooExtendType(); - } + }, ]); $typeGenerator = $this->getTypeGenerator(); @@ -221,7 +225,7 @@ public function testGlobTypeMapperExtend(): void $cache = new Psr16Cache(new ArrayAdapter()); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), $cache); + $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); $type = $mapper->mapClassToType(TestObject::class, null); @@ -232,12 +236,12 @@ public function testGlobTypeMapperExtend(): void $this->assertFalse($mapper->canExtendTypeForName('NotExists', $type)); // Again to test cache - $anotherMapperSameCache = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), $cache); + $anotherMapperSameCache = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); $this->assertTrue($anotherMapperSameCache->canExtendTypeForClass(TestObject::class, $type)); $this->assertTrue($anotherMapperSameCache->canExtendTypeForName('TestObject', $type)); $this->expectException(CannotMapTypeException::class); - $mapper->extendTypeForClass(\stdClass::class, $type); + $mapper->extendTypeForClass(stdClass::class, $type); } public function testEmptyGlobTypeMapper(): void @@ -249,7 +253,7 @@ public function testEmptyGlobTypeMapper(): void $cache = new Psr16Cache(new ArrayAdapter()); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Integration\Controllers'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), $cache); + $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Integration\Controllers'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); $this->assertSame([], $mapper->getSupportedClasses()); } @@ -257,9 +261,9 @@ public function testEmptyGlobTypeMapper(): void public function testGlobTypeMapperDecorate(): void { $container = new LazyContainer([ - FilterDecorator::class => function () { + FilterDecorator::class => static function () { return new FilterDecorator(); - } + }, ]); $typeGenerator = $this->getTypeGenerator(); @@ -267,9 +271,9 @@ public function testGlobTypeMapperDecorate(): void $cache = new Psr16Cache(new ArrayAdapter()); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Integration\Types'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), $cache); + $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Integration\Types'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); - $inputType = new MockResolvableInputObjectType(['name'=>'FilterInput']); + $inputType = new MockResolvableInputObjectType(['name' => 'FilterInput']); $mapper->decorateInputTypeForName('FilterInput', $inputType); @@ -283,14 +287,14 @@ public function testGlobTypeMapperDecorate(): void public function testInvalidName(): void { $container = new LazyContainer([ - FooType::class => function () { + FooType::class => static function () { return new FooType(); - } + }, ]); $typeGenerator = $this->getTypeGenerator(); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), new Psr16Cache(new ArrayAdapter())); + $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), new Psr16Cache(new ArrayAdapter())); $this->assertFalse($mapper->canExtendTypeForName('{}()/\\@:', new MutableObjectType(['name' => 'foo']))); $this->assertFalse($mapper->canDecorateInputTypeForName('{}()/\\@:', new MockResolvableInputObjectType(['name' => 'foo']))); @@ -300,13 +304,13 @@ public function testInvalidName(): void public function testGlobTypeMapperExtendBadName(): void { $container = new LazyContainer([ - FooType::class => function () { + FooType::class => static function () { return new FooType(); }, - FooExtendType::class => function () { + FooExtendType::class => static function () { return new FooExtendType(); }, - BadExtendType::class => function () { + BadExtendType::class => static function () { return new BadExtendType(); }, ]); @@ -316,7 +320,7 @@ public function testGlobTypeMapperExtendBadName(): void $cache = new Psr16Cache(new ArrayAdapter()); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\BadExtendType'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), $cache); + $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\BadExtendType'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); $testObjectType = new MutableObjectType([ 'name' => 'TestObject', @@ -333,13 +337,13 @@ public function testGlobTypeMapperExtendBadName(): void public function testGlobTypeMapperExtendBadClass(): void { $container = new LazyContainer([ - FooType::class => function () { + FooType::class => static function () { return new FooType(); }, - FooExtendType::class => function () { + FooExtendType::class => static function () { return new FooExtendType(); }, - BadExtendType2::class => function () { + BadExtendType2::class => static function () { return new BadExtendType2(); }, ]); @@ -349,7 +353,7 @@ public function testGlobTypeMapperExtendBadClass(): void $cache = new Psr16Cache(new ArrayAdapter()); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\BadExtendType2'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), $cache); + $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\BadExtendType2'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); $testObjectType = new MutableObjectType([ 'name' => 'TestObject', @@ -366,9 +370,9 @@ public function testGlobTypeMapperExtendBadClass(): void public function testNonInstantiableType(): void { $container = new LazyContainer([ - FooType::class => function () { + FooType::class => static function () { return new FooType(); - } + }, ]); $typeGenerator = $this->getTypeGenerator(); @@ -376,7 +380,7 @@ public function testNonInstantiableType(): void $cache = new Psr16Cache(new ArrayAdapter()); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\NonInstantiableType'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), $cache); + $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\NonInstantiableType'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); $this->expectException(GraphQLRuntimeException::class); $this->expectExceptionMessage('Class "TheCodingMachine\GraphQLite\Fixtures\NonInstantiableType\AbstractFooType" annotated with @Type(class="TheCodingMachine\GraphQLite\Fixtures\TestObject") must be instantiable.'); @@ -391,7 +395,7 @@ public function testNonInstantiableInput(): void $inputTypeGenerator = $this->getInputTypeGenerator(); $cache = new Psr16Cache(new ArrayAdapter()); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\NonInstantiableInput'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), $cache); + $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\NonInstantiableInput'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); $this->expectException(FailedResolvingInputType::class); $this->expectExceptionMessage("Class 'TheCodingMachine\GraphQLite\Fixtures\NonInstantiableInput\AbstractFoo' annotated with @Input must be instantiable."); diff --git a/tests/Mappers/Parameters/ContainerParameterMapperTest.php b/tests/Mappers/Parameters/ContainerParameterMapperTest.php index cbb261b42e..a33e8295ae 100644 --- a/tests/Mappers/Parameters/ContainerParameterMapperTest.php +++ b/tests/Mappers/Parameters/ContainerParameterMapperTest.php @@ -1,5 +1,7 @@ getRegistry()); - $refMethod = new ReflectionMethod(__CLASS__, 'dummy'); + $refMethod = new ReflectionMethod(self::class, 'dummy'); $parameter = $refMethod->getParameters()[0]; $this->expectException(MissingAutowireTypeException::class); $this->expectExceptionMessage('For parameter $foo in TheCodingMachine\GraphQLite\Mappers\Parameters\ContainerParameterMapperTest::dummy, annotated with annotation @Autowire, you must either provide a type-hint or specify the container identifier with @Autowire(identifier="my_service")'); - $mapper->mapParameter($parameter, - new DocBlock(), null, $this->getAnnotationReader()->getParameterAnnotations($parameter), new class implements ParameterHandlerInterface { - public function mapParameter(ReflectionParameter $parameter, DocBlock $docBlock, ?Type $paramTagType, ParameterAnnotations $parameterAnnotations): ParameterInterface + $mapper->mapParameter( + $parameter, + new DocBlock(), + null, + $this->getAnnotationReader()->getParameterAnnotationsPerParameter([$parameter])['foo'], + new class implements ParameterHandlerInterface { + public function mapParameter(ReflectionParameter $parameter, DocBlock $docBlock, Type|null $paramTagType, ParameterAnnotations $parameterAnnotations): ParameterInterface { } - }); + }, + ); } - /** - * @Autowire(for="foo") - */ - private function dummy($foo) { - + private function dummy( + #[Autowire] + $foo, + ): void + { } } diff --git a/tests/Mappers/Parameters/TypeMapperTest.php b/tests/Mappers/Parameters/TypeMapperTest.php index dd1327dde5..825cdaacb0 100644 --- a/tests/Mappers/Parameters/TypeMapperTest.php +++ b/tests/Mappers/Parameters/TypeMapperTest.php @@ -1,8 +1,9 @@ getDocBlock($refMethod); $this->expectException(CannotMapTypeException::class); - $this->expectExceptionMessage('For return type of TheCodingMachine\GraphQLite\Mappers\Parameters\TypeMapperTest::dummy, in GraphQL, you can only use union types between objects. These types cannot be used in union types: Int!, String!'); + $this->expectExceptionMessage('For return type of TheCodingMachine\GraphQLite\Mappers\Parameters\TypeMapperTest::dummy, in GraphQL, you can only use union types between objects. These types cannot be used in union types: String!, Int!'); $typeMapper->mapReturnType($refMethod, $docBlockObj); } @@ -80,14 +83,13 @@ public function testMapObjectNullableUnionWorks(): void $gqType = $typeMapper->mapReturnType($refMethod, $docBlockObj); $this->assertNotInstanceOf(NonNull::class, $gqType); - assert(!($gqType instanceof NonNull)); + assert(! ($gqType instanceof NonNull)); $this->assertInstanceOf(UnionType::class, $gqType); assert($gqType instanceof UnionType); $unionTypes = $gqType->getTypes(); $this->assertEquals(2, count($unionTypes)); $this->assertEquals('TestObject', $unionTypes[0]->name); $this->assertEquals('TestObject2', $unionTypes[1]->name); - } public function testHideParameter(): void @@ -104,7 +106,7 @@ public function testHideParameter(): void $refMethod = new ReflectionMethod($this, 'withDefaultValue'); $refParameter = $refMethod->getParameters()[0]; $docBlockObj = $cachedDocBlockFactory->getDocBlock($refMethod); - $annotations = $this->getAnnotationReader()->getParameterAnnotations($refParameter); + $annotations = $this->getAnnotationReader()->getParameterAnnotationsPerParameter([$refParameter])['foo']; $param = $typeMapper->mapParameter($refParameter, $docBlockObj, null, $annotations); @@ -129,7 +131,7 @@ public function testParameterWithDescription(): void $docBlockObj = $cachedDocBlockFactory->getDocBlock($refMethod); $refParameter = $refMethod->getParameters()[0]; - $parameter = $typeMapper->mapParameter($refParameter, $docBlockObj, null, $this->getAnnotationReader()->getParameterAnnotations($refParameter)); + $parameter = $typeMapper->mapParameter($refParameter, $docBlockObj, null, $this->getAnnotationReader()->getParameterAnnotationsPerParameter([$refParameter])['foo']); $this->assertInstanceOf(InputTypeParameter::class, $parameter); assert($parameter instanceof InputTypeParameter); $this->assertEquals('Foo parameter', $parameter->getDescription()); @@ -149,7 +151,7 @@ public function testHideParameterException(): void $refMethod = new ReflectionMethod($this, 'withoutDefaultValue'); $refParameter = $refMethod->getParameters()[0]; $docBlockObj = $cachedDocBlockFactory->getDocBlock($refMethod); - $annotations = $this->getAnnotationReader()->getParameterAnnotations($refParameter); + $annotations = $this->getAnnotationReader()->getParameterAnnotationsPerParameter([$refParameter])['foo']; $this->expectException(CannotHideParameterRuntimeException::class); $this->expectExceptionMessage('For parameter $foo of method TheCodingMachine\GraphQLite\Mappers\Parameters\TypeMapperTest::withoutDefaultValue(), cannot use the @HideParameter annotation. The parameter needs to provide a default value.'); @@ -157,35 +159,22 @@ public function testHideParameterException(): void $typeMapper->mapParameter($refParameter, $docBlockObj, null, $annotations); } - /** - * @return int|string - */ - private function dummy() + private function dummy(): int|string { - } - /** - * @param int $foo Foo parameter - */ - private function withParamDescription(int $foo) + /** @param int $foo Foo parameter */ + private function withParamDescription(int $foo): void { - } - /** - * @HideParameter(for="$foo") - */ - private function withDefaultValue($foo = 24) + private function withDefaultValue(#[HideParameter] + $foo = 24,): void { - } - /** - * @HideParameter(for="$foo") - */ - private function withoutDefaultValue($foo) + private function withoutDefaultValue(#[HideParameter] + $foo,): void { - } } diff --git a/tests/Mappers/RecursiveTypeMapperTest.php b/tests/Mappers/RecursiveTypeMapperTest.php index 6d67e3bdd1..8fc14ee27c 100644 --- a/tests/Mappers/RecursiveTypeMapperTest.php +++ b/tests/Mappers/RecursiveTypeMapperTest.php @@ -1,26 +1,22 @@ 'Foobar' - ]); + $objectType = new MutableObjectType(['name' => 'Foobar']); $typeMapper = new StaticTypeMapper( - types: [ - ClassB::class => $objectType - ] + types: [ClassB::class => $objectType], ); $recursiveTypeMapper = new RecursiveTypeMapper( @@ -46,7 +37,7 @@ public function testMapClassToType(): void new NamingStrategy(), new Psr16Cache(new ArrayAdapter()), $this->getTypeRegistry(), - $this->getAnnotationReader() + $this->getAnnotationReader(), ); $this->assertFalse($typeMapper->canMapClassToType(ClassC::class)); @@ -60,14 +51,10 @@ public function testMapClassToType(): void public function testMapNameToType(): void { - $objectType = new MutableObjectType([ - 'name' => 'Foobar' - ]); + $objectType = new MutableObjectType(['name' => 'Foobar']); $typeMapper = new StaticTypeMapper( - types: [ - ClassB::class => $objectType - ] + types: [ClassB::class => $objectType], ); $recursiveTypeMapper = new RecursiveTypeMapper( @@ -75,7 +62,7 @@ public function testMapNameToType(): void new NamingStrategy(), new Psr16Cache(new ArrayAdapter()), $this->getTypeRegistry(), - $this->getAnnotationReader() + $this->getAnnotationReader(), ); $this->assertTrue($recursiveTypeMapper->canMapNameToType('Foobar')); @@ -97,17 +84,12 @@ public function testMapNameToType2(): void $recursiveMapper->mapNameToType('NotExists'); } - public function testMapClassToInputType(): void { - $inputObjectType = new InputObjectType([ - 'name' => 'Foobar' - ]); + $inputObjectType = new InputObjectType(['name' => 'Foobar']); $typeMapper = new StaticTypeMapper( - inputTypes: [ - ClassB::class => $inputObjectType - ] + inputTypes: [ClassB::class => $inputObjectType], ); $recursiveTypeMapper = new RecursiveTypeMapper( @@ -115,7 +97,7 @@ public function testMapClassToInputType(): void new NamingStrategy(), new Psr16Cache(new ArrayAdapter()), $this->getTypeRegistry(), - $this->getAnnotationReader() + $this->getAnnotationReader(), ); $this->assertFalse($recursiveTypeMapper->canMapClassToInputType(ClassC::class)); @@ -126,33 +108,32 @@ public function testMapClassToInputType(): void protected $typeMapper; - protected function getTypeMapper() + protected function getTypeMapper(): RecursiveTypeMapper { if ($this->typeMapper === null) { $container = new LazyContainer([ - ClassAType::class => function () { + ClassAType::class => static function () { return new ClassAType(); }, - ClassBType::class => function () { + ClassBType::class => static function () { return new ClassBType(); - } + }, ]); $namingStrategy = new NamingStrategy(); - $compositeMapper = new CompositeTypeMapper(); $this->typeMapper = new RecursiveTypeMapper( $compositeMapper, new NamingStrategy(), new Psr16Cache(new ArrayAdapter()), $this->getTypeRegistry(), - $this->getAnnotationReader() + $this->getAnnotationReader(), ); $typeGenerator = new TypeGenerator($this->getAnnotationReader(), $namingStrategy, $this->getTypeRegistry(), $this->getRegistry(), $this->typeMapper, $this->getFieldsBuilder()); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Interfaces\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), $namingStrategy, $this->typeMapper, new Psr16Cache(new NullAdapter())); + $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Interfaces\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(), $namingStrategy, $this->typeMapper, new Psr16Cache(new NullAdapter())); $compositeMapper->addTypeMapper($mapper); } return $this->typeMapper; @@ -198,20 +179,14 @@ public function testGetOutputTypes(): void public function testDuplicateDetection(): void { - $objectType = new MutableObjectType([ - 'name' => 'Foobar' - ]); + $objectType = new MutableObjectType(['name' => 'Foobar']); $typeMapper1 = new StaticTypeMapper( - types: [ - ClassB::class => $objectType - ] + types: [ClassB::class => $objectType], ); $typeMapper2 = new StaticTypeMapper( - types: [ - ClassA::class => $objectType - ] + types: [ClassA::class => $objectType], ); $compositeTypeMapper = new CompositeTypeMapper(); @@ -223,7 +198,7 @@ public function testDuplicateDetection(): void new NamingStrategy(), new Psr16Cache(new ArrayAdapter()), $this->getTypeRegistry(), - $this->getAnnotationReader() + $this->getAnnotationReader(), ); $this->expectException(DuplicateMappingException::class); @@ -243,7 +218,7 @@ public function testMapNoTypes(): void new NamingStrategy(), new Psr16Cache(new ArrayAdapter()), $this->getTypeRegistry(), - $this->getAnnotationReader() + $this->getAnnotationReader(), ); $this->expectException(TypeNotFoundException::class); @@ -257,14 +232,14 @@ public function testMapNameToTypeDecorators(): void $cache = new Psr16Cache(new ArrayAdapter()); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Integration'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $this->getRegistry(), new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), $cache); + $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Integration'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $this->getRegistry(), new \TheCodingMachine\GraphQLite\AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); $recursiveTypeMapper = new RecursiveTypeMapper( $mapper, new NamingStrategy(), new Psr16Cache(new ArrayAdapter()), $this->getTypeRegistry(), - $this->getAnnotationReader() + $this->getAnnotationReader(), ); $type = $recursiveTypeMapper->mapNameToType('FilterInput'); diff --git a/tests/Mappers/StaticClassListTypeMapperTest.php b/tests/Mappers/StaticClassListTypeMapperTest.php index c69ede9964..c77beb3929 100644 --- a/tests/Mappers/StaticClassListTypeMapperTest.php +++ b/tests/Mappers/StaticClassListTypeMapperTest.php @@ -2,7 +2,6 @@ namespace TheCodingMachine\GraphQLite\Mappers; -use Doctrine\Common\Annotations\AnnotationReader; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Psr16Cache; use Symfony\Component\Cache\Simple\ArrayCache; @@ -22,7 +21,7 @@ public function testClassListException(): void $cache = new Psr16Cache(new ArrayAdapter()); - $mapper = new StaticClassListTypeMapper(['NotExistsClass'], $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), $cache); + $mapper = new StaticClassListTypeMapper(['NotExistsClass'], $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); $this->expectException(GraphQLRuntimeException::class); $this->expectExceptionMessage('Could not find class "NotExistsClass"'); diff --git a/tests/SchemaFactoryTest.php b/tests/SchemaFactoryTest.php index d57dc78a11..31666902a7 100644 --- a/tests/SchemaFactoryTest.php +++ b/tests/SchemaFactoryTest.php @@ -4,7 +4,6 @@ namespace TheCodingMachine\GraphQLite; -use Doctrine\Common\Annotations\AnnotationReader; use GraphQL\Error\DebugFlag; use GraphQL\Executor\ExecutionResult; use GraphQL\GraphQL; @@ -71,8 +70,7 @@ public function testSetters(): void $factory->addControllerNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers'); $factory->addTypeNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); - $factory->setDoctrineAnnotationReader(new AnnotationReader()) - ->setAuthenticationService(new VoidAuthenticationService()) + $factory->setAuthenticationService(new VoidAuthenticationService()) ->setAuthorizationService(new VoidAuthorizationService()) ->setNamingStrategy(new NamingStrategy()) ->addTypeMapper(new CompositeTypeMapper()) diff --git a/website/docs/CHANGELOG.md b/website/docs/CHANGELOG.md index feb5972c91..fc3f57dba6 100644 --- a/website/docs/CHANGELOG.md +++ b/website/docs/CHANGELOG.md @@ -138,9 +138,9 @@ public function toGraphQLInputType(Type $type, ?InputType $subType, string $argu ### New features -- [@Input](annotations-reference.md#input-annotation) annotation is introduced as an alternative to `@Factory`. Now GraphQL input type can be created in the same manner as `@Type` in combination with `@Field` - [example](input-types.mdx#input-attribute). +- [@Input](annotations-reference.md#input-annotation) annotation is introduced as an alternative to `#[Factory]`. Now GraphQL input type can be created in the same manner as `#[Type]` in combination with `#[Field]` - [example](input-types.mdx#input-attribute). - New attributes has been added to [@Field](annotations-reference.md#field-annotation) annotation: `for`, `inputType` and `description`. -- The following annotations now can be applied to class properties directly: `@Field`, `@Logged`, `@Right`, `@FailWith`, `@HideIfUnauthorized` and `@Security`. +- The following annotations now can be applied to class properties directly: `#[Field]`, `#[Logged]`, `#[Right]`, `@FailWith`, `@HideIfUnauthorized` and `#[Security]`. ## 4.1.0 @@ -174,21 +174,21 @@ changed. ### New features -- You can directly [annotate a PHP interface with `@Type` to make it a GraphQL interface](inheritance-interfaces.mdx#mapping-interfaces) +- You can directly [annotate a PHP interface with `#[Type]` to make it a GraphQL interface](inheritance-interfaces.mdx#mapping-interfaces) - You can autowire services in resolvers, thanks to the new `@Autowire` annotation -- Added [user input validation](validation.mdx) (using the Symfony Validator or the Laravel validator or a custom `@Assertion` annotation +- Added [user input validation](validation.mdx) (using the Symfony Validator or the Laravel validator or a custom `#[Assertion]` annotation - Improved security handling: - Unauthorized access to fields can now generate GraphQL errors (rather that schema errors in GraphQLite v3) - - Added fine-grained security using the `@Security` annotation. A field can now be [marked accessible or not depending on the context](fine-grained-security.mdx). + - Added fine-grained security using the `#[Security]` annotation. A field can now be [marked accessible or not depending on the context](fine-grained-security.mdx). For instance, you can restrict access to the field "viewsCount" of the type `BlogPost` only for post that the current user wrote. - - You can now inject the current logged user in any query / mutation / field using the `@InjectUser` annotation + - You can now inject the current logged user in any query / mutation / field using the `#[InjectUser]` annotation - Performance: - You can inject the [Webonyx query plan in a parameter from a resolver](query-plan.mdx) - You can use the [dataloader pattern to improve performance drastically via the "prefetchMethod" attribute](prefetch-method.mdx) - Customizable error handling has been added: - You can throw [many errors in one exception](error-handling.mdx#many-errors-for-one-exception) with `TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException` - You can force input types using `@UseInputType(for="$id", inputType="ID!")` -- You can extend an input types (just like you could extend an output type in v3) using [the new `@Decorate` annotation](extend-input-type.mdx) +- You can extend an input types (just like you could extend an output type in v3) using [the new `#[Decorate]` annotation](extend-input-type.mdx) - In a factory, you can [exclude some optional parameters from the GraphQL schema](input-types#ignoring-some-parameters) Many extension points have been added diff --git a/website/docs/README.mdx b/website/docs/README.mdx index 0293578109..0df34daa98 100644 --- a/website/docs/README.mdx +++ b/website/docs/README.mdx @@ -5,9 +5,6 @@ slug: / sidebar_label: GraphQLite --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -

GraphQLite logo

@@ -26,14 +23,6 @@ file uploads, security, validation, extendable types and more! First, declare a query in your controller: - - - ```php class ProductController { @@ -45,35 +34,8 @@ class ProductController } ``` - - - -```php -class ProductController -{ - /** - * @Query() - */ - public function product(string $id): Product - { - // Some code that looks for a product and returns it. - } -} -``` - - - - Then, annotate the `Product` class to declare what fields are exposed to the GraphQL API: - - - ```php #[Type] class Product @@ -87,29 +49,6 @@ class Product } ``` - - - -```php -/** - * @Type() - */ -class Product -{ - /** - * @Field() - */ - public function getName(): string - { - return $this->name; - } - // ... -} -``` - - - - That's it, you're good to go! Query and enjoy! ```graphql diff --git a/website/docs/annotations-reference.md b/website/docs/annotations-reference.md index e838cbf987..485b1f561c 100644 --- a/website/docs/annotations-reference.md +++ b/website/docs/annotations-reference.md @@ -1,15 +1,15 @@ --- id: annotations-reference -title: Annotations reference -sidebar_label: Annotations reference +title: Attributes reference +sidebar_label: Attributes reference --- -Note: all annotations are available both in a Doctrine annotation format (`@Query`) and in PHP 8 attribute format (`#[Query]`). +Note: all annotations are available in PHP 8 attribute format (`#[Query]`), support of Doctrine annotation format was dropped. See [Doctrine annotations vs PHP 8 attributes](doctrine-annotations-attributes.mdx) for more details. -## @Query +## #[Query] -The `@Query` annotation is used to declare a GraphQL query. +The `#[Query]` attribute is used to declare a GraphQL query. **Applies on**: controller methods. @@ -18,9 +18,9 @@ Attribute | Compulsory | Type | Definition name | *no* | string | The name of the query. If skipped, the name of the method is used instead. [outputType](custom-types.mdx) | *no* | string | Forces the GraphQL output type of a query. -## @Mutation +## #[Mutation] -The `@Mutation` annotation is used to declare a GraphQL mutation. +The `#[Mutation]` attribute is used to declare a GraphQL mutation. **Applies on**: controller methods. @@ -29,9 +29,9 @@ Attribute | Compulsory | Type | Definition name | *no* | string | The name of the mutation. If skipped, the name of the method is used instead. [outputType](custom-types.mdx) | *no* | string | Forces the GraphQL output type of a query. -## @Subscription +## #[Subscription] -The `@Subscription` annotation is used to declare a GraphQL subscription. +The `#[Subscription]` attribute is used to declare a GraphQL subscription. **Applies on**: controller methods. @@ -40,36 +40,36 @@ Attribute | Compulsory | Type | Definition name | *no* | string | The name of the subscription. If skipped, the name of the method is used instead. [outputType](custom-types.mdx) | *no* | string | Defines the GraphQL output type that will be sent for the subscription. -## @Type +## #[Type] -The `@Type` annotation is used to declare a GraphQL object type. This is used with standard output -types, as well as enum types. For input types, use the [@Input annotation](#input-annotation) directly on the input type or a [@Factory annoation](#factory-annotation) to make/return an input type. +The `#[Type]` attribute is used to declare a GraphQL object type. This is used with standard output +types, as well as enum types. For input types, use the [#[Input] attribute](#input-annotation) directly on the input type or a [#[Factory] attribute](#factory-annotation) to make/return an input type. **Applies on**: classes. Attribute | Compulsory | Type | Definition ---------------|------------|------|-------- -class | *no* | string | The targeted class/enum for the actual type. If no "class" attribute is passed, the type applies to the current class/enum. The current class/enum is assumed to be an entity (not service). If the "class" attribute *is passed*, [the class/enum annotated with `@Type` becomes a service](external-type-declaration.mdx). +class | *no* | string | The targeted class/enum for the actual type. If no "class" attribute is passed, the type applies to the current class/enum. The current class/enum is assumed to be an entity (not service). If the "class" attribute *is passed*, [the class/enum annotated with `#[Type]` becomes a service](external-type-declaration.mdx). name | *no* | string | The name of the GraphQL type generated. If not passed, the name of the class is used. If the class ends with "Type", the "Type" suffix is removed default | *no* | bool | Defaults to *true*. Whether the targeted PHP class should be mapped by default to this type. external | *no* | bool | Whether this is an [external type declaration](external-type-declaration.mdx) or not. You usually do not need to use this attribute since this value defaults to true if a "class" attribute is set. This is only useful if you are declaring a type with no PHP class mapping using the "name" attribute. -## @ExtendType +## #[ExtendType] -The `@ExtendType` annotation is used to add fields to an existing GraphQL object type. +The `#[ExtendType]` attribute is used to add fields to an existing GraphQL object type. **Applies on**: classes. Attribute | Compulsory | Type | Definition ---------------|------------|------|-------- -class | see below | string | The targeted class. [The class annotated with `@ExtendType` is a service](extend-type.mdx). +class | see below | string | The targeted class. [The class annotated with `#[ExtendType]` is a service](extend-type.mdx). name | see below | string | The targeted GraphQL output type. One and only one of "class" and "name" parameter can be passed at the same time. -## @Input +## #[Input] -The `@Input` annotation is used to declare a GraphQL input type. +The `#[Input]` attribute is used to declare a GraphQL input type. **Applies on**: classes. @@ -80,11 +80,11 @@ description | *no* | string | Description of the input type in the docu default | *no* | bool | Name of the input type represented in your GraphQL schema. Defaults to `true` *only if* the name is not specified. If `name` is specified, this will default to `false`, so must also be included for `true` when `name` is used. update | *no* | bool | Determines if the the input represents a partial update. When set to `true` all input fields will become optional and won't have default values thus won't be set on resolve if they are not specified in the query/mutation/subscription. This primarily applies to nullable fields. -## @Field +## #[Field] -The `@Field` annotation is used to declare a GraphQL field. +The `#[Field]` attribute is used to declare a GraphQL field. -**Applies on**: methods or properties of classes annotated with `@Type`, `@ExtendType` or `@Input`. +**Applies on**: methods or properties of classes annotated with `#[Type]`, `#[ExtendType]` or `#[Input]`. When it's applied on private or protected property, public getter or/and setter method is expected in the class accordingly whether it's used for output type or input type. For example if property name is `foo` then getter should be `getFoo()` or setter should be `setFoo($foo)`. Setter can be omitted if property related to the field is present in the constructor with the same name. @@ -96,11 +96,11 @@ description | *no* | string | Field description d [outputType](custom-types.mdx) | *no* | string | Forces the GraphQL output type of a query. [inputType](input-types.mdx) | *no* | string | Forces the GraphQL input type of a query. -## @SourceField +## #[SourceField] -The `@SourceField` annotation is used to declare a GraphQL field. +The `#[SourceField]` attribute is used to declare a GraphQL field. -**Applies on**: classes annotated with `@Type` or `@ExtendType`. +**Applies on**: classes annotated with `#[Type]` or `#[ExtendType]`. Attribute | Compulsory | Type | Definition ---------------|------------|------|-------- @@ -109,15 +109,15 @@ name | *yes* | string | The name of the field. phpType | *no* | string | The PHP type of the field (as you would write it in a Docblock) description | *no* | string | Field description displayed in the GraphQL docs. If it's empty PHP doc comment of the method in the source class is used instead. sourceName | *no* | string | The name of the property in the source class. If not set, the `name` will be used to get property value. -annotations | *no* | array\ | A set of annotations that apply to this field. You would typically used a "@Logged" or "@Right" annotation here. Available in Doctrine annotations only (not available in the #SourceField PHP 8 attribute) +annotations | *no* | array\ | A set of annotations that apply to this field. You would typically used a "#[Logged]" or "#[Right]" attribute as class here. **Note**: `outputType` and `phpType` are mutually exclusive. -## @MagicField +## #[MagicField] -The `@MagicField` annotation is used to declare a GraphQL field that originates from a PHP magic property (using `__get` magic method). +The `#[MagicField]` attribute is used to declare a GraphQL field that originates from a PHP magic property (using `__get` magic method). -**Applies on**: classes annotated with `@Type` or `@ExtendType`. +**Applies on**: classes annotated with `#[Type]` or `#[ExtendType]`. Attribute | Compulsory | Type | Definition ---------------|------------|------|-------- @@ -126,114 +126,114 @@ name | *yes* | string | The name of the field. phpType | *no*(*) | string | The PHP type of the field (as you would write it in a Docblock) description | *no* | string | Field description displayed in the GraphQL docs. If not set, no description will be shown. sourceName | *no* | string | The name of the property in the source class. If not set, the `name` will be used to get property value. -annotations | *no* | array\ | A set of annotations that apply to this field. You would typically used a "@Logged" or "@Right" annotation here. Available in Doctrine annotations only (not available in the #MagicField PHP 8 attribute) +annotations | *no* | array\ | A set of annotations that apply to this field. You would typically used a "#[Logged]" or "#[Right]" attribute as class here. (*) **Note**: `outputType` and `phpType` are mutually exclusive. You MUST provide one of them. -## @Prefetch +## #[Prefetch] Marks field parameter to be used for [prefetching](prefetch-method.mdx). -**Applies on**: parameters of methods annotated with `@Query`, `@Mutation` or `@Field`. +**Applies on**: parameters of methods annotated with `#[Query]`, `#[Mutation]` or `#[Field]`. Attribute | Compulsory | Type | Definition ------------------------------|------------|----------|-------- callable | *no* | callable | Name of the prefetch method (in same class) or a full callable, either a static method or regular service from the container -## @Logged +## #[Logged] -The `@Logged` annotation is used to declare a Query/Mutation/Field is only visible to logged users. +The `#[Logged]` attribute is used to declare a Query/Mutation/Field is only visible to logged users. -**Applies on**: methods or properties annotated with `@Query`, `@Mutation` or `@Field`. +**Applies on**: methods or properties annotated with `#[Query]`, `#[Mutation]` or `#[Field]`. -This annotation allows no attributes. +This attribute allows no arguments. -## @Right +## #[Right] -The `@Right` annotation is used to declare a Query/Mutation/Field is only visible to users with a specific right. +The `#[Right]` attribute is used to declare a Query/Mutation/Field is only visible to users with a specific right. -**Applies on**: methods or properties annotated with `@Query`, `@Mutation` or `@Field`. +**Applies on**: methods or properties annotated with `#[Query]`, `#[Mutation]` or `#[Field]`. Attribute | Compulsory | Type | Definition ---------------|------------|------|-------- name | *yes* | string | The name of the right. -## @FailWith +## #[FailWith] -The `@FailWith` annotation is used to declare a default value to return in the user is not authorized to see a specific -query/mutation/subscription/field (according to the `@Logged` and `@Right` annotations). +The `#[FailWith]` attribute is used to declare a default value to return in the user is not authorized to see a specific +query/mutation/subscription/field (according to the `#[Logged]` and `#[Right]` attributes). -**Applies on**: methods or properties annotated with `@Query`, `@Mutation` or `@Field` and one of `@Logged` or `@Right` annotations. +**Applies on**: methods or properties annotated with `#[Query]`, `#[Mutation]` or `#[Field]` and one of `#[Logged]` or `#[Right]` attributes. Attribute | Compulsory | Type | Definition ---------------|------------|------|-------- value | *yes* | mixed | The value to return if the user is not authorized. -## @HideIfUnauthorized +## #[HideIfUnauthorized] -
This annotation only works when a Schema is used to handle exactly one use request. +
This attribute only works when a Schema is used to handle exactly one use request. If you serve your GraphQL API from long-running standalone servers (like Laravel Octane, Swoole, RoadRunner etc) and -share the same Schema instance between multiple requests, please avoid using @HideIfUnauthorized.
+share the same Schema instance between multiple requests, please avoid using #[HideIfUnauthorized].
-The `@HideIfUnauthorized` annotation is used to completely hide the query/mutation/subscription/field if the user is not authorized -to access it (according to the `@Logged` and `@Right` annotations). +The `#[HideIfUnauthorized]` attribute is used to completely hide the query/mutation/subscription/field if the user is not authorized +to access it (according to the `#[Logged]` and `#[Right]` attributes). -**Applies on**: methods or properties annotated with `@Query`, `@Mutation` or `@Field` and one of `@Logged` or `@Right` annotations. +**Applies on**: methods or properties annotated with `#[Query]`, `#[Mutation]` or `#[Field]` and one of `#[Logged]` or `#[Right]` attributes. -`@HideIfUnauthorized` and `@FailWith` are mutually exclusive. +`#[HideIfUnauthorized]` and `#[FailWith]` are mutually exclusive. -## @InjectUser +## #[InjectUser] -Use the `@InjectUser` annotation to inject an instance of the current user logged in into a parameter of your +Use the `#[InjectUser]` attribute to inject an instance of the current user logged in into a parameter of your query/mutation/subscription/field. See [the authentication and authorization page](authentication-authorization.mdx) for more details. -**Applies on**: methods annotated with `@Query`, `@Mutation` or `@Field`. +**Applies on**: methods annotated with `#[Query]`, `#[Mutation]` or `#[Field]`. Attribute | Compulsory | Type | Definition ---------------|------------|--------|-------- *for* | *yes* | string | The name of the PHP parameter -## @Security +## #[Security] -The `@Security` annotation can be used to check fin-grained access rights. +The `#[Security]` attribute can be used to check fin-grained access rights. It is very flexible: it allows you to pass an expression that can contains custom logic. See [the fine grained security page](fine-grained-security.mdx) for more details. -**Applies on**: methods or properties annotated with `@Query`, `@Mutation` or `@Field`. +**Applies on**: methods or properties annotated with `#[Query]`, `#[Mutation]` or `#[Field]`. Attribute | Compulsory | Type | Definition ---------------|------------|--------|-------- *default* | *yes* | string | The security expression -## @Factory +## #[Factory] -The `@Factory` annotation is used to declare a factory that turns GraphQL input types into objects. +The `#[Factory]` attribute is used to declare a factory that turns GraphQL input types into objects. **Applies on**: methods from classes in the "types" namespace. Attribute | Compulsory | Type | Definition ---------------|------------|------|-------- name | *no* | string | The name of the input type. If skipped, the name of class returned by the factory is used instead. -default | *no* | bool | If `true`, this factory will be used by default for its PHP return type. If set to `false`, you must explicitly [reference this factory using the `@Parameter` annotation](input-types.mdx#declaring-several-input-types-for-the-same-php-class). +default | *no* | bool | If `true`, this factory will be used by default for its PHP return type. If set to `false`, you must explicitly [reference this factory using the `#[Parameter]` attribute](input-types.mdx#declaring-several-input-types-for-the-same-php-class). -## @UseInputType +## #[UseInputType] Used to override the GraphQL input type of a PHP parameter. -**Applies on**: methods annotated with `@Query`, `@Mutation` or `@Field` annotation. +**Applies on**: methods annotated with `#[Query]`, `#[Mutation]` or `#[Field]` attribute. Attribute | Compulsory | Type | Definition ---------------|------------|------|-------- *for* | *yes* | string | The name of the PHP parameter *inputType* | *yes* | string | The GraphQL input type to force for this input field -## @Decorate +## #[Decorate] -The `@Decorate` annotation is used [to extend/modify/decorate an input type declared with the `@Factory` annotation](extend-input-type.mdx). +The `#[Decorate]` attribute is used [to extend/modify/decorate an input type declared with the `#[Factory]` attribute](extend-input-type.mdx). **Applies on**: methods from classes in the "types" namespace. @@ -241,20 +241,20 @@ Attribute | Compulsory | Type | Definition ---------------|------------|------|-------- name | *yes* | string | The GraphQL input type name extended by this decorator. -## @Autowire +## #[Autowire] [Resolves a PHP parameter from the container](autowiring.mdx). -Useful to inject services directly into `@Field` method arguments. +Useful to inject services directly into `#[Field]` method arguments. -**Applies on**: methods annotated with `@Query`, `@Mutation` or `@Field` annotation. +**Applies on**: methods annotated with `#[Query]`, `#[Mutation]` or `#[Field]` attribute. Attribute | Compulsory | Type | Definition ---------------|------------|------|-------- *for* | *yes* | string | The name of the PHP parameter *identifier* | *no* | string | The identifier of the service to fetch. This is optional. Please avoid using this attribute as this leads to a "service locator" anti-pattern. -## @HideParameter +## #[HideParameter] Removes [an argument from the GraphQL schema](input-types.mdx#ignoring-some-parameters). @@ -262,7 +262,7 @@ Attribute | Compulsory | Type | Definition ---------------|------------|------|-------- *for* | *yes* | string | The name of the PHP parameter to hide -## @Cost +## #[Cost] Sets complexity and multipliers on fields for [automatic query complexity](operation-complexity.md#static-request-analysis). @@ -272,13 +272,13 @@ Attribute | Compulsory | Type | Definition *multipliers* | *no* | array\ | Names of fields by value of which complexity will be multiplied *defaultMultiplier* | *no* | int | Default multiplier value if all multipliers are missing/null -## @Validate +## #[Validate] -
This annotation is only available in the GraphQLite Laravel package
+
This attribute is only available in the GraphQLite Laravel package
[Validates a user input in Laravel](laravel-package-advanced.mdx). -**Applies on**: methods annotated with `@Query`, `@Mutation`, `@Field`, `@Factory` or `@Decorator` annotation. +**Applies on**: methods annotated with `#[Query]`, `#[Mutation]`, `#[Field]`, `#[Factory]` or `#[Decorator]` attribute. Attribute | Compulsory | Type | Definition ---------------|------------|------|-------- @@ -288,26 +288,26 @@ Attribute | Compulsory | Type | Definition Sample: ```php -@Validate(for="$email", rule="email|unique:users") +#[Validate(for: "$email", rule: "email|unique:users")] ``` -## @Assertion +## #[Assertion] [Validates a user input](validation.mdx). -The `@Assertion` annotation is available in the *thecodingmachine/graphqlite-symfony-validator-bridge* third party package. +The `#[Assertion]` attribute is available in the *thecodingmachine/graphqlite-symfony-validator-bridge* third party package. It is available out of the box if you use the Symfony bundle. -**Applies on**: methods annotated with `@Query`, `@Mutation`, `@Field`, `@Factory` or `@Decorator` annotation. +**Applies on**: methods annotated with `#[Query]`, `#[Mutation]`, `#[Field]`, `#[Factory]` or `#[Decorator]` attribute. Attribute | Compulsory | Type | Definition ---------------|------------|------|-------- *for* | *yes* | string | The name of the PHP parameter -*constraint* | *yes | annotation | One (or many) Symfony validation annotations. +*constraint* | *yes | annotation | One (or many) Symfony validation attributes. ## ~~@EnumType~~ -*Deprecated: Use [PHP 8.1's native Enums](https://www.php.net/manual/en/language.types.enumerations.php) instead with a [@Type](#type-annotation).* +*Deprecated: Use [PHP 8.1's native Enums](https://www.php.net/manual/en/language.types.enumerations.php) instead with a [#[Type]](#type-annotation).* The `@EnumType` annotation is used to change the name of a "Enum" type. Note that if you do not want to change the name, the annotation is optionnal. Any object extending `MyCLabs\Enum\Enum` diff --git a/website/docs/argument-resolving.md b/website/docs/argument-resolving.md index 35556c66d0..8125964dae 100644 --- a/website/docs/argument-resolving.md +++ b/website/docs/argument-resolving.md @@ -24,8 +24,8 @@ As an example, GraphQLite uses *parameter middlewares* internally to: In the query above, the `$info` argument is filled with the Webonyx `ResolveInfo` class thanks to the [`ResolveInfoParameterHandler parameter middleware`](https://github.com/thecodingmachine/graphqlite/blob/master/src/Mappers/Parameters/ResolveInfoParameterHandler.php) -- Inject a service from the container when you use the `@Autowire` annotation -- Perform validation with the `@Validate` annotation (in Laravel package) +- Inject a service from the container when you use the `#[Autowire]` attribute +- Perform validation with the `#[Validate]` attribute (in Laravel package) @@ -54,23 +54,22 @@ interface ParameterMiddlewareInterface Then, resolution actually happen by executing the resolver (this is the second pass). -## Annotations parsing +## Attributes parsing -If you plan to use annotations while resolving arguments, your annotation should extend the [`ParameterAnnotationInterface`](https://github.com/thecodingmachine/graphqlite/blob/master/src/Annotations/ParameterAnnotationInterface.php) +If you plan to use attributes while resolving arguments, your attribute class should extend the [`ParameterAnnotationInterface`](https://github.com/thecodingmachine/graphqlite/blob/master/src/Annotations/ParameterAnnotationInterface.php) -For instance, if we want GraphQLite to inject a service in an argument, we can use `@Autowire(for="myService")`. +For instance, if we want GraphQLite to inject a service in an argument, we can use `#[Autowire]`. -For PHP 8 attributes, we only need to put declare the annotation can target parameters: `#[Attribute(Attribute::TARGET_PARAMETER)]`. +We only need to put declare the annotation can target parameters: `#[Attribute(Attribute::TARGET_PARAMETER)]`. -The annotation looks like this: +The class looks like this: ```php use Attribute; /** - * Use this annotation to autowire a service from the container into a given parameter of a field/query/mutation. + * Use this attribute to autowire a service from the container into a given parameter of a field/query/mutation. * - * @Annotation */ #[Attribute(Attribute::TARGET_PARAMETER)] class Autowire implements ParameterAnnotationInterface diff --git a/website/docs/authentication-authorization.mdx b/website/docs/authentication-authorization.mdx index f716d02d30..9842e2a3d3 100644 --- a/website/docs/authentication-authorization.mdx +++ b/website/docs/authentication-authorization.mdx @@ -4,18 +4,15 @@ title: Authentication and authorization sidebar_label: Authentication and authorization --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - You might not want to expose your GraphQL API to anyone. Or you might want to keep some queries/mutations/subscriptions or fields reserved to some users. GraphQLite offers some control over what a user can do with your API. You can restrict access to resources: -- based on authentication using the [`@Logged` annotation](#logged-and-right-annotations) (restrict access to logged users) -- based on authorization using the [`@Right` annotation](#logged-and-right-annotations) (restrict access to logged users with certain rights). -- based on fine-grained authorization using the [`@Security` annotation](fine-grained-security.mdx) (restrict access for some given resources to some users). +- based on authentication using the [`#[Logged]` attribute](#logged-and-right-annotations) (restrict access to logged users) +- based on authorization using the [`#[Right]` attribute](#logged-and-right-annotations) (restrict access to logged users with certain rights). +- based on fine-grained authorization using the [`#[Security]` attribute](fine-grained-security.mdx) (restrict access for some given resources to some users).
GraphQLite does not have its own security mechanism. @@ -23,16 +20,9 @@ resources: See Connecting GraphQLite to your framework's security module.
-## `@Logged` and `@Right` annotations +## `#[Logged]` and `#[Right]` attributes -GraphQLite exposes two annotations (`@Logged` and `@Right`) that you can use to restrict access to a resource. - - +GraphQLite exposes two attributes (`#[Logged]` and `#[Right]`) that you can use to restrict access to a resource. ```php namespace App\Controller; @@ -56,43 +46,14 @@ class UserController } ``` - - - -```php -namespace App\Controller; - -use TheCodingMachine\GraphQLite\Annotations\Query; -use TheCodingMachine\GraphQLite\Annotations\Logged; -use TheCodingMachine\GraphQLite\Annotations\Right; - -class UserController -{ - /** - * @Query - * @Logged - * @Right("CAN_VIEW_USER_LIST") - * @return User[] - */ - public function users(int $limit, int $offset): array - { - // ... - } -} -``` - - - - - In the example above, the query `users` will only be available if the user making the query is logged AND if he has the `CAN_VIEW_USER_LIST` right. -`@Logged` and `@Right` annotations can be used next to: +`#[Logged]` and `#[Right]` attributes can be used next to: -* `@Query` annotations -* `@Mutation` annotations -* `@Field` annotations +* `#[Query]` attributes +* `#[Mutation]` attributes +* `#[Field]` attributes
By default, if a user tries to access an unauthorized query/mutation/subscription/field, an error is @@ -102,17 +63,9 @@ has the `CAN_VIEW_USER_LIST` right. ## Not throwing errors If you do not want an error to be thrown when a user attempts to query a field/query/mutation/subscription -they have no access to, you can use the `@FailWith` annotation. - -The `@FailWith` annotation contains the value that will be returned for users with insufficient rights. +they have no access to, you can use the `#[FailWith]` attribute. - - +The `#[FailWith]` attribute contains the value that will be returned for users with insufficient rights. ```php class UserController @@ -134,43 +87,9 @@ class UserController } ``` - - - -```php -class UserController -{ - /** - * If a user is not logged or if the user has not the right "CAN_VIEW_USER_LIST", - * the value returned will be "null". - * - * @Query - * @Logged - * @Right("CAN_VIEW_USER_LIST") - * @FailWith(null) - * @return User[] - */ - public function users(int $limit, int $offset): array - { - // ... - } -} -``` - - - - ## Injecting the current user as a parameter -Use the `@InjectUser` annotation to get an instance of the current user logged in. - - - +Use the `#[InjectUser]` attribute to get an instance of the current user logged in. ```php namespace App\Controller; @@ -181,9 +100,9 @@ use TheCodingMachine\GraphQLite\Annotations\InjectUser; class ProductController { /** - * @Query * @return Product */ + #[Query] public function product( int $id, #[InjectUser] @@ -195,41 +114,15 @@ class ProductController } ``` - - - -```php -namespace App\Controller; - -use TheCodingMachine\GraphQLite\Annotations\Query; -use TheCodingMachine\GraphQLite\Annotations\InjectUser; - -class ProductController -{ - /** - * @Query - * @InjectUser(for="$user") - * @return Product - */ - public function product(int $id, User $user): Product - { - // ... - } -} -``` - - - - -The `@InjectUser` annotation can be used next to: +The `#[InjectUser]` attribute can be used next to: -* `@Query` annotations -* `@Mutation` annotations -* `@Field` annotations +* `#[Query]` attributes +* `#[Mutation]` attributes +* `#[Field]` attributes The object injected as the current user depends on your framework. It is in fact the object returned by the ["authentication service" configured in GraphQLite](implementing-security.md). If user is not authenticated and -parameter's type is not nullable, an authorization exception is thrown, similar to `@Logged` annotation. +parameter's type is not nullable, an authorization exception is thrown, similar to `#[Logged]` attribute. ## Hiding fields / queries / mutations / subscriptions @@ -237,15 +130,7 @@ By default, a user analysing the GraphQL schema can see all queries/mutations/su Some will be available to him and some won't. If you want to add an extra level of security (or if you want your schema to be kept secret to unauthorized users), -you can use the `@HideIfUnauthorized` annotation. Beware of [it's limitations](annotations-reference.md). - - - +you can use the `#[HideIfUnauthorized]` attribute. Beware of [it's limitations](annotations-reference.md). ```php class UserController @@ -268,33 +153,6 @@ class UserController } ``` - - - -```php -class UserController -{ - /** - * If a user is not logged or if the user has not the right "CAN_VIEW_USER_LIST", - * the schema will NOT contain the "users" query at all (so trying to call the - * "users" query will result in a GraphQL "query not found" error. - * - * @Query - * @Logged - * @Right("CAN_VIEW_USER_LIST") - * @HideIfUnauthorized() - * @return User[] - */ - public function users(int $limit, int $offset): array - { - // ... - } -} -``` - - - - While this is the most secured mode, it can have drawbacks when working with development tools (you need to be logged as admin to fetch the complete schema). diff --git a/website/docs/autowiring.mdx b/website/docs/autowiring.mdx index c38bb73d77..825241afe0 100644 --- a/website/docs/autowiring.mdx +++ b/website/docs/autowiring.mdx @@ -4,14 +4,11 @@ title: Autowiring services sidebar_label: Autowiring services --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - GraphQLite can automatically inject services in your fields/queries/mutations signatures. Some of your fields may be computed. In order to compute these fields, you might need to call a service. -Most of the time, your `@Type` annotation will be put on a model. And models do not have access to services. +Most of the time, your `#[Type]` attribute will be put on a model. And models do not have access to services. Hopefully, if you add a type-hinted service in your field's declaration, GraphQLite will automatically fill it with the service instance. @@ -20,14 +17,6 @@ the service instance. Let's assume you are running an international store. You have a `Product` class. Each product has many names (depending on the language of the user). - - - ```php namespace App\Entities; @@ -53,39 +42,6 @@ class Product } ``` - - - -```php -namespace App\Entities; - -use TheCodingMachine\GraphQLite\Annotations\Autowire; -use TheCodingMachine\GraphQLite\Annotations\Field; -use TheCodingMachine\GraphQLite\Annotations\Type; - -use Symfony\Component\Translation\TranslatorInterface; - -/** - * @Type() - */ -class Product -{ - // ... - - /** - * @Field() - * @Autowire(for="$translator") - */ - public function getName(TranslatorInterface $translator): string - { - return $translator->trans('product_name_'.$this->id); - } -} -``` - - - - When GraphQLite queries the name, it will automatically fetch the translator service.
As with most autowiring solutions, GraphQLite assumes that the service identifier @@ -130,30 +86,10 @@ By type-hinting against an interface, your code remains testable and is decouple Optionally, you can specify the identifier of the service you want to fetch from the controller: - - - ```php #[Autowire(identifier: "translator")] ``` - - - -```php -/** - * @Autowire(for="$translator", identifier="translator") - */ -``` - - - -
While GraphQLite offers the possibility to specify the name of the service to be autowired, we would like to emphasize that this is highly discouraged. Hard-coding a container identifier in the code of your class is akin to using the "service locator" pattern, which is known to be an diff --git a/website/docs/custom-types.mdx b/website/docs/custom-types.mdx index 800ab2846c..527e91623c 100644 --- a/website/docs/custom-types.mdx +++ b/website/docs/custom-types.mdx @@ -4,21 +4,10 @@ title: Custom types sidebar_label: Custom types --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - In some special cases, you want to override the GraphQL return type that is attributed by default by GraphQLite. For instance: - - - ```php #[Type(class: Product::class)] class ProductType @@ -31,28 +20,6 @@ class ProductType } ``` - - - -```php -/** - * @Type(class=Product::class) - */ -class ProductType -{ - /** - * @Field - */ - public function getId(Product $source): string - { - return $source->getId(); - } -} -``` - - - - In the example above, GraphQLite will generate a GraphQL schema with a field `id` of type `string`: ```graphql @@ -66,42 +33,22 @@ is an `ID` or not. You can help GraphQLite by manually specifying the output type to use: - - - ```php #[Field(outputType: "ID")] ``` - - - -```php - /** - * @Field(name="id", outputType="ID") - */ -``` - - - - ## Usage The `outputType` attribute will map the return value of the method to the output type passed in parameter. You can use the `outputType` attribute in the following annotations: -* `@Query` -* `@Mutation` -* `@Subscription` -* `@Field` -* `@SourceField` -* `@MagicField` +* `#[Query]` +* `#[Mutation]` +* `#[Subscription]` +* `#[Field]` +* `#[SourceField]` +* `#[MagicField]` ## Registering a custom output type (advanced) diff --git a/website/docs/doctrine-annotations-attributes.mdx b/website/docs/doctrine-annotations-attributes.mdx index 72725a39b2..058c276a95 100644 --- a/website/docs/doctrine-annotations-attributes.mdx +++ b/website/docs/doctrine-annotations-attributes.mdx @@ -4,48 +4,12 @@ title: Doctrine annotations VS PHP8 attributes sidebar_label: Annotations VS Attributes --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - GraphQLite is heavily relying on the concept of annotations (also called attributes in PHP 8+). ## Doctrine annotations -
- Deprecated! Doctrine annotations are deprecated in favor of native PHP 8 attributes. Support will be dropped in a future release. -
- -Historically, attributes were not available in PHP and PHP developers had to "trick" PHP to get annotation support. This was the purpose of the [doctrine/annotation](https://www.doctrine-project.org/projects/doctrine-annotations/en/latest/index.html) library. - -Using Doctrine annotations, you write annotations in your docblocks: - -```php -use TheCodingMachine\GraphQLite\Annotations\Type; - -/** - * @Type - */ -class MyType -{ -} -``` - -Please note that: - -- The annotation is added in a **docblock** (a comment starting with "`/**`") -- The `Type` part is actually a class. It must be declared in the `use` statements at the top of your file. - - -
- Heads up! -

Some IDEs provide support for Doctrine annotations:

- - - We strongly recommend using an IDE that has Doctrine annotations support. +
+ Unsupported! Doctrine annotations are replaced in favor of native PHP 8 attributes.
## PHP 8 attributes @@ -71,19 +35,10 @@ They support the same attributes too. A few notable differences: -- PHP 8 attributes do not support nested attributes (unlike Doctrine annotations). This means there is no equivalent to the `annotations` attribute of `@MagicField` and `@SourceField`. - PHP 8 attributes can be written at the parameter level. Any attribute targeting a "parameter" must be written at the parameter level. Let's take an example with the [`#Autowire` attribute](autowiring.mdx): - - - ```php #[Field] public function getProduct(#[Autowire] ProductRepository $productRepository) : Product { @@ -91,23 +46,6 @@ public function getProduct(#[Autowire] ProductRepository $productRepository) : P } ``` - - - -```php -/** - * @Field - * @Autowire(for="$productRepository") - */ -public function getProduct(ProductRepository $productRepository) : Product { - //... -} -``` - - - - - ## Migrating from Doctrine annotations to PHP 8 attributes The good news is that you can easily migrate from Doctrine annotations to PHP 8 attributes using the amazing, [Rector library](https://github.com/rectorphp/rector). To do so, you'll want to use the following rector configuration: diff --git a/website/docs/error-handling.mdx b/website/docs/error-handling.mdx index 231f07db72..549a9c15c9 100644 --- a/website/docs/error-handling.mdx +++ b/website/docs/error-handling.mdx @@ -4,9 +4,6 @@ title: Error handling sidebar_label: Error handling --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - In GraphQL, when an error occurs, the server must add an "error" entry in the response. ```json @@ -142,14 +139,6 @@ throw only one exception. If you want to display several exceptions, you can bundle these exceptions in a `GraphQLAggregateException` that you can throw. - - - ```php use TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException; @@ -171,35 +160,6 @@ public function createProduct(string $name, float $price): Product } ``` - - - -```php -use TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException; - -/** - * @Query - */ -public function createProduct(string $name, float $price): Product -{ - $exceptions = new GraphQLAggregateException(); - - if ($name === '') { - $exceptions->add(new GraphQLException('Name cannot be empty', 400, null, 'VALIDATION')); - } - if ($price <= 0) { - $exceptions->add(new GraphQLException('Price must be positive', 400, null, 'VALIDATION')); - } - - if ($exceptions->hasExceptions()) { - throw $exceptions; - } -} -``` - - - - ## Webonyx exceptions GraphQLite is based on the wonderful webonyx/GraphQL-PHP library. Therefore, the Webonyx exception mechanism can diff --git a/website/docs/extend-input-type.mdx b/website/docs/extend-input-type.mdx index 767856e32f..abe2818dd9 100644 --- a/website/docs/extend-input-type.mdx +++ b/website/docs/extend-input-type.mdx @@ -4,36 +4,25 @@ title: Extending an input type sidebar_label: Extending an input type --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - Available in GraphQLite 4.0+ -
If you are not familiar with the @Factory tag, read first the "input types" guide.
+
If you are not familiar with the #[Factory] tag, read first the "input types" guide.
Fields exposed in a GraphQL input type do not need to be all part of the factory method. -Just like with output type (that can be [extended using the `ExtendType` annotation](extend-type.mdx)), you can extend/modify -an input type using the `@Decorate` annotation. +Just like with output type (that can be [extended using the `ExtendType` attribute](extend-type.mdx)), you can extend/modify +an input type using the `#[Decorate]` attribute. -Use the `@Decorate` annotation to add additional fields to an input type that is already declared by a `@Factory` annotation, +Use the `#[Decorate]` attribute to add additional fields to an input type that is already declared by a `#[Factory]` attribute, or to modify the returned object.
- The @Decorate annotation is very useful in scenarios where you cannot touch the @Factory method. - This can happen if the @Factory method is defined in a third-party library or if the @Factory method is part + The #[Decorate] attribute is very useful in scenarios where you cannot touch the #[Factory] method. + This can happen if the #[Factory] method is defined in a third-party library or if the #[Factory] method is part of auto-generated code.
-Let's assume you have a `Filter` class used as an input type. You most certainly have a `@Factory` to create the input type. - - - +Let's assume you have a `Filter` class used as an input type. You most certainly have a `#[Factory]` to create the input type. ```php class MyFactory @@ -49,39 +38,9 @@ class MyFactory } ``` - - - -```php -class MyFactory -{ - /** - * @Factory() - */ - public function createFilter(string $name): Filter - { - // Let's assume you have a flexible 'Filter' class that can accept any kind of filter - $filter = new Filter(); - $filter->addFilter('name', $name); - return $filter; - } -} -``` - - - - Assuming you **cannot** modify the code of this factory, you can still modify the GraphQL input type generated by adding a "decorator" around the factory. - - - ```php class MyDecorator { @@ -94,26 +53,6 @@ class MyDecorator } ``` - - - -```php -class MyDecorator -{ - /** - * @Decorate(inputTypeName="FilterInput") - */ - public function addTypeFilter(Filter $filter, string $type): Filter - { - $filter->addFilter('type', $type); - return $filter; - } -} -``` - - - - In the example above, the "Filter" input type is modified. We add an additional "type" field to the input type. A few things to notice: @@ -121,8 +60,8 @@ A few things to notice: - The decorator takes the object generated by the factory as first argument - The decorator MUST return an object of the same type (or a sub-type) - The decorator CAN contain additional parameters. They will be added to the fields of the GraphQL input type. -- The `@Decorate` annotation must contain a `inputTypeName` attribute that contains the name of the GraphQL input type - that is decorated. If you did not specify this name in the `@Factory` annotation, this is by default the name of the +- The `#[Decorate]` attribute must contain a `inputTypeName` attribute that contains the name of the GraphQL input type + that is decorated. If you did not specify this name in the `#[Factory]` attribute, this is by default the name of the PHP class + "Input" (for instance: "Filter" => "FilterInput") diff --git a/website/docs/extend-type.mdx b/website/docs/extend-type.mdx index c8efb8d920..44d5ac9327 100644 --- a/website/docs/extend-type.mdx +++ b/website/docs/extend-type.mdx @@ -4,12 +4,10 @@ title: Extending a type sidebar_label: Extending a type --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; Fields exposed in a GraphQL type do not need to be all part of the same class. -Use the `@ExtendType` annotation to add additional fields to a type that is already declared. +Use the `#[ExtendType]` attribute to add additional fields to a type that is already declared.
Extending a type has nothing to do with type inheritance. @@ -20,14 +18,6 @@ Use the `@ExtendType` annotation to add additional fields to a type that is alre Let's assume you have a `Product` class. In order to get the name of a product, there is no `getName()` method in the product because the name needs to be translated in the correct language. You have a `TranslationService` to do that. - - - ```php namespace App\Entities; @@ -53,57 +43,12 @@ class Product } ``` - - - -```php -namespace App\Entities; - -use TheCodingMachine\GraphQLite\Annotations\Field; -use TheCodingMachine\GraphQLite\Annotations\Type; - -/** - * @Type() - */ -class Product -{ - // ... - - /** - * @Field() - */ - public function getId(): string - { - return $this->id; - } - - /** - * @Field() - */ - public function getPrice(): ?float - { - return $this->price; - } -} -``` - - - - ```php // You need to use a service to get the name of the product in the correct language. $name = $translationService->getProductName($productId, $language); ``` -Using `@ExtendType`, you can add an additional `name` field to your product: - - - +Using `#[ExtendType]`, you can add an additional `name` field to your product: ```php namespace App\Types; @@ -130,68 +75,13 @@ class ProductType } ``` - - - -```php -namespace App\Types; - -use TheCodingMachine\GraphQLite\Annotations\ExtendType; -use TheCodingMachine\GraphQLite\Annotations\Field; -use App\Entities\Product; - -/** - * @ExtendType(class=Product::class) - */ -class ProductType -{ - private $translationService; - - public function __construct(TranslationServiceInterface $translationService) - { - $this->translationService = $translationService; - } - - /** - * @Field() - */ - public function getName(Product $product, string $language): string - { - return $this->translationService->getProductName($product->getId(), $language); - } -} -``` - - - - Let's break this sample: - - - ```php #[ExtendType(class: Product::class)] ``` - - - -```php -/** - * @ExtendType(class=Product::class) - */ -``` - - - - -With the `@ExtendType` annotation, we tell GraphQLite that we want to add fields in the GraphQL type mapped to +With the `#[ExtendType]` attribute, we tell GraphQLite that we want to add fields in the GraphQL type mapped to the `Product` PHP class. ```php @@ -218,14 +108,6 @@ If you are using the Symfony bundle (or a framework with autowiring like Laravel is usually not an issue as the container will automatically create the controller entry if you do not explicitly declare it.
- - - ```php #[Field] public function getName(Product $product, string $language): string @@ -234,23 +116,7 @@ public function getName(Product $product, string $language): string } ``` - - - -```php -/** - * @Field() - */ -public function getName(Product $product, string $language): string -{ - return $this->translationService->getProductName($product->getId(), $language); -} -``` - - - - -The `@Field` annotation is used to add the "name" field to the `Product` type. +The `#[Field]` attribute is used to add the "name" field to the `Product` type. Take a close look at the signature. The first parameter is the "resolved object" we are working on. Any additional parameters are used as arguments. diff --git a/website/docs/external-type-declaration.mdx b/website/docs/external-type-declaration.mdx index d11cd9ad77..428fad7f8e 100644 --- a/website/docs/external-type-declaration.mdx +++ b/website/docs/external-type-declaration.mdx @@ -4,28 +4,17 @@ title: External type declaration sidebar_label: External type declaration --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - -In some cases, you cannot or do not want to put an annotation on a domain class. +In some cases, you cannot or do not want to put an attribute on a domain class. For instance: * The class you want to annotate is part of a third party library and you cannot modify it -* You are doing domain-driven design and don't want to clutter your domain object with annotations from the view layer +* You are doing domain-driven design and don't want to clutter your domain object with attributes from the view layer * etc. -## `@Type` annotation with the `class` attribute - -GraphQLite allows you to use a *proxy* class thanks to the `@Type` annotation with the `class` attribute: +## `#[Type]` attribute with the `class` attribute - - +GraphQLite allows you to use a *proxy* class thanks to the `#[Type]` attribute with the `class` attribute: ```php namespace App\Types; @@ -45,34 +34,6 @@ class ProductType } ``` - - - -```php -namespace App\Types; - -use TheCodingMachine\GraphQLite\Annotations\Type; -use TheCodingMachine\GraphQLite\Annotations\Field; -use App\Entities\Product; - -/** - * @Type(class=Product::class) - */ -class ProductType -{ - /** - * @Field() - */ - public function getId(Product $product): string - { - return $product->getId(); - } -} -``` - - - - The `ProductType` class must be in the *types* namespace. You configured this namespace when you installed GraphQLite. The `ProductType` class is actually a **service**. You can therefore inject dependencies in it. @@ -82,19 +43,11 @@ If you are using the Symfony bundle (or a framework with autowiring like Laravel is usually not an issue as the container will automatically create the controller entry if you do not explicitly declare it.
-In methods with a `@Field` annotation, the first parameter is the *resolved object* we are working on. Any additional parameters are used as arguments. - -## `@SourceField` annotation +In methods with a `#[Field]` attribute, the first parameter is the *resolved object* we are working on. Any additional parameters are used as arguments. -If you don't want to rewrite all *getters* of your base class, you may use the `@SourceField` annotation: +## `#[SourceField]` attribute - - +If you don't want to rewrite all *getters* of your base class, you may use the `#[SourceField]` attribute: ```php use TheCodingMachine\GraphQLite\Annotations\Type; @@ -109,43 +62,14 @@ class ProductType } ``` - - - -```php -use TheCodingMachine\GraphQLite\Annotations\Type; -use TheCodingMachine\GraphQLite\Annotations\SourceField; -use App\Entities\Product; - -/** - * @Type(class=Product::class) - * @SourceField(name="name") - * @SourceField(name="price") - */ -class ProductType -{ -} -``` - - - - By doing so, you let GraphQLite know that the type exposes the `getName` method of the underlying `Product` object. Internally, GraphQLite will look for methods named `name()`, `getName()` and `isName()`). You can set different name to look for with `sourceName` attribute. -## `@MagicField` annotation - -If your object has no getters, but instead uses magic properties (using the magic `__get` method), you should use the `@MagicField` annotation: +## `#[MagicField]` attribute - - +If your object has no getters, but instead uses magic properties (using the magic `__get` method), you should use the `#[MagicField]` attribute: ```php use TheCodingMachine\GraphQLite\Annotations\Type; @@ -163,30 +87,6 @@ class ProductType } ``` - - - -```php -use TheCodingMachine\GraphQLite\Annotations\Type; -use TheCodingMachine\GraphQLite\Annotations\SourceField; -use App\Entities\Product; - -/** - * @Type() - * @MagicField(name="name", outputType="String!") - * @MagicField(name="price", outputType="Float") - */ -class ProductType -{ - public function __get(string $property) { - // return some magic property - } -} -``` - - - - By doing so, you let GraphQLite know that the type exposes "name" and the "price" magic properties of the underlying `Product` object. You can set different name to look for with `sourceName` attribute. @@ -197,7 +97,7 @@ of each property manually. ### Authentication and authorization -You may also check for logged users or users with a specific right using the "annotations" property. +You may also check for logged users or users with a specific right using the "annotations" argument. ```php use TheCodingMachine\GraphQLite\Annotations\Type; @@ -207,32 +107,20 @@ use TheCodingMachine\GraphQLite\Annotations\Right; use TheCodingMachine\GraphQLite\Annotations\FailWith; use App\Entities\Product; -/** - * @Type(class=Product::class) - * @SourceField(name="name") - * @SourceField(name="price", annotations={@Logged, @Right(name="CAN_ACCESS_Price", @FailWith(null)})) - */ +#[Type(class: Product::class)] +#[SourceField(name: "name")] +#[SourceField(name: "price", annotations: [new Logged(), new Right("CAN_ACCESS_Price"), new FailWith(null)])] class ProductType extends AbstractAnnotatedObjectType { } ``` -Any annotations described in the [Authentication and authorization page](authentication-authorization.mdx), or any annotation this is actually a ["field middleware"](field-middlewares.md) can be used in the `@SourceField` "annotations" attribute. - -
Heads up! The "annotation" attribute in @SourceField and @MagicField is only available as a Doctrine annotations. You cannot use it in PHP 8 attributes (because PHP 8 attributes cannot be nested)
+Any attributes described in the [Authentication and authorization page](authentication-authorization.mdx), or any attribute this is actually a ["field middleware"](field-middlewares.md) can be used in the `#[SourceField]` "annotations" argument. -## Declaring fields dynamically (without annotations) +## Declaring fields dynamically (without attributes) -In some very particular cases, you might not know exactly the list of `@SourceField` annotations at development time. -If you need to decide the list of `@SourceField` at runtime, you can implement the `FromSourceFieldsInterface`: - - - +In some very particular cases, you might not know exactly the list of `#[SourceField]` attributes at development time. +If you need to decide the list of `#[SourceField]` at runtime, you can implement the `FromSourceFieldsInterface`: ```php use TheCodingMachine\GraphQLite\FromSourceFieldsInterface; @@ -251,38 +139,7 @@ class ProductType implements FromSourceFieldsInterface // You may want to enable fields conditionally based on feature flags... if (ENABLE_STATUS_GLOBALLY) { return [ - new SourceField(['name'=>'status', 'logged'=>true]), - ]; - } else { - return []; - } - } -} -``` - - - - -```php -use TheCodingMachine\GraphQLite\FromSourceFieldsInterface; - -/** - * @Type(class=Product::class) - */ -class ProductType implements FromSourceFieldsInterface -{ - /** - * Dynamically returns the array of source fields - * to be fetched from the original object. - * - * @return SourceFieldInterface[] - */ - public function getSourceFields(): array - { - // You may want to enable fields conditionally based on feature flags... - if (ENABLE_STATUS_GLOBALLY) { - return [ - new SourceField(['name'=>'status', 'logged'=>true]), + new SourceField(['name'=>'status', 'annotations'=>[new Logged()]]), ]; } else { return []; @@ -290,6 +147,3 @@ class ProductType implements FromSourceFieldsInterface } } ``` - - - diff --git a/website/docs/field-middlewares.md b/website/docs/field-middlewares.md index 18eac15b30..df7b2bfaee 100644 --- a/website/docs/field-middlewares.md +++ b/website/docs/field-middlewares.md @@ -1,15 +1,15 @@ --- id: field-middlewares -title: Adding custom annotations with Field middlewares -sidebar_label: Custom annotations +title: Adding custom attributes with Field middlewares +sidebar_label: Custom attributes --- Available in GraphQLite 4.0+ -Just like the `@Logged` or `@Right` annotation, you can develop your own annotation that extends/modifies the behaviour of a field/query/mutation. +Just like the `#[Logged]` or `#[Right]` attribute, you can develop your own attribute that extends/modifies the behaviour of a field/query/mutation.
- If you want to create an annotation that targets a single argument (like @AutoWire(for="$service")), you should rather check the documentation about custom argument resolving + If you want to create an attribute that targets a single argument (like #[AutoWire]), you should rather check the documentation about custom argument resolving
## Field middlewares @@ -63,16 +63,16 @@ If you want the field to purely disappear, your middleware can return `null`, al field middlewares only get called once per Schema instance. If you use a long-running server (like Laravel Octane, Swoole, RoadRunner etc) and share the same Schema instance across requests, you will not be able to hide fields based on request data. -## Annotations parsing +## Attributes parsing Take a look at the `QueryFieldDescriptor::getMiddlewareAnnotations()`. -It returns the list of annotations applied to your field that implements the `MiddlewareAnnotationInterface`. +It returns the list of attributes applied to your field that implements the `MiddlewareAnnotationInterface`. -Let's imagine you want to add a `@OnlyDebug` annotation that displays a field/query/mutation only in debug mode (and +Let's imagine you want to add a `#[OnlyDebug]` attribute that displays a field/query/mutation only in debug mode (and hides the field in production). That could be useful, right? -First, we have to define the annotation. Annotations are handled by the great [doctrine/annotations](https://www.doctrine-project.org/projects/doctrine-annotations/en/1.6/index.html) library (for PHP 7+) and/or by PHP 8 attributes. +First, we have to define the attribute. ```php title="OnlyDebug.php" namespace App\Annotations; @@ -80,19 +80,15 @@ namespace App\Annotations; use Attribute; use TheCodingMachine\GraphQLite\Annotations\MiddlewareAnnotationInterface; -/** - * @Annotation - * @Target({"METHOD", "ANNOTATION"}) - */ #[Attribute(Attribute::TARGET_METHOD)] class OnlyDebug implements MiddlewareAnnotationInterface { } ``` -Apart from being a classical annotation/attribute, this class implements the `MiddlewareAnnotationInterface`. This interface is a "marker" interface. It does not have any methods. It is just used to tell GraphQLite that this annotation is to be used by middlewares. +Apart from being a classical attribute, this class implements the `MiddlewareAnnotationInterface`. This interface is a "marker" interface. It does not have any methods. It is just used to tell GraphQLite that this attribute is to be used by middlewares. -Now, we can write a middleware that will act upon this annotation. +Now, we can write a middleware that will act upon this attribute. ```php namespace App\Middlewares; @@ -103,7 +99,7 @@ use GraphQL\Type\Definition\FieldDefinition; use TheCodingMachine\GraphQLite\QueryFieldDescriptor; /** - * Middleware in charge of hiding a field if it is annotated with @OnlyDebug and the DEBUG constant is not set + * Middleware in charge of hiding a field if it is annotated with #[OnlyDebug] and the DEBUG constant is not set */ class OnlyDebugFieldMiddleware implements FieldMiddlewareInterface { @@ -117,7 +113,7 @@ class OnlyDebugFieldMiddleware implements FieldMiddlewareInterface $onlyDebug = $annotations->getAnnotationByType(OnlyDebug::class); if ($onlyDebug !== null && !DEBUG) { - // If the onlyDebug annotation is present, returns null. + // If the onlyDebug attribute is present, returns null. // Returning null will hide the field. return null; } diff --git a/website/docs/file-uploads.mdx b/website/docs/file-uploads.mdx index 8c0782f89b..487a413948 100644 --- a/website/docs/file-uploads.mdx +++ b/website/docs/file-uploads.mdx @@ -4,9 +4,6 @@ title: File uploads sidebar_label: File uploads --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - GraphQL does not support natively the notion of file uploads, but an extension to the GraphQL protocol was proposed to add support for [multipart requests](https://github.com/jaydenseric/graphql-multipart-request-spec). @@ -40,14 +37,6 @@ for more information on how to integrate it in your framework. To handle an uploaded file, you type-hint against the PSR-7 `UploadedFileInterface`: - - - ```php class MyController { @@ -60,26 +49,6 @@ class MyController } ``` - - - -```php -class MyController -{ - /** - * @Mutation - */ - public function saveDocument(string $name, UploadedFileInterface $file): Document - { - // Some code that saves the document. - $file->moveTo($someDir); - } -} -``` - - - - Of course, you need to use a GraphQL client that is compatible with multipart requests. See [jaydenseric/graphql-multipart-request-spec](https://github.com/jaydenseric/graphql-multipart-request-spec#client) for a list of compatible clients. The GraphQL client must send the file using the Upload type. diff --git a/website/docs/fine-grained-security.mdx b/website/docs/fine-grained-security.mdx index f5d2fff032..3fac998ff4 100644 --- a/website/docs/fine-grained-security.mdx +++ b/website/docs/fine-grained-security.mdx @@ -4,29 +4,19 @@ title: Fine grained security sidebar_label: Fine grained security --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -If the [`@Logged` and `@Right` annotations](authentication-authorization.mdx#logged-and-right-annotations) are not -granular enough for your needs, you can use the advanced `@Security` annotation. +If the [`#[Logged]` and `#[Right]` attributes](authentication-authorization.mdx#logged-and-right-annotations) are not +granular enough for your needs, you can use the advanced `#[Security]` attribute. -Using the `@Security` annotation, you can write an *expression* that can contain custom logic. For instance: +Using the `#[Security]` attribute, you can write an *expression* that can contain custom logic. For instance: - Check that a user can access a given resource - Check that a user has one right or another right - ... -## Using the @Security annotation +## Using the #[Security] attribute -The `@Security` annotation is very flexible: it allows you to pass an expression that can contains custom logic: - - - +The `#[Security]` attribute is very flexible: it allows you to pass an expression that can contains custom logic: ```php use TheCodingMachine\GraphQLite\Annotations\Security; @@ -41,33 +31,12 @@ public function getPost(Post $post): array } ``` - - - -```php -use TheCodingMachine\GraphQLite\Annotations\Security; - -// ... - -/** - * @Query - * @Security("is_granted('ROLE_ADMIN') or is_granted('POST_SHOW', post)") - */ -public function getPost(Post $post): array -{ - // ... -} -``` - - - - -The *expression* defined in the `@Security` annotation must conform to [Symfony's Expression Language syntax](https://symfony.com/doc/4.4/components/expression_language/syntax.html) +The *expression* defined in the `#[Security]` attribute must conform to [Symfony's Expression Language syntax](https://symfony.com/doc/4.4/components/expression_language/syntax.html)
- If you are a Symfony user, you might already be used to the @Security annotation. Most of the inspiration - of this annotation comes from Symfony. Warning though! GraphQLite's @Security annotation and - Symfony's @Security annotation are slightly different. Especially, the two annotations do not live + If you are a Symfony user, you might already be used to the #[Security] attribute. Most of the inspiration + of this attribute comes from Symfony. Warning though! GraphQLite's #[Security] attribute and + Symfony's #[Security] attribute are slightly different. Especially, the two attributes do not live in the same namespace!
@@ -75,62 +44,18 @@ The *expression* defined in the `@Security` annotation must conform to [Symfony' Use the `is_granted` function to check if a user has a special right. - - - ```php #[Security("is_granted('ROLE_ADMIN')")] ``` - - - -```php -@Security("is_granted('ROLE_ADMIN')") -``` - - - - is similar to - - - ```php #[Right("ROLE_ADMIN")] ``` - - - -```php -@Right("ROLE_ADMIN") -``` - - - - In addition, the `is_granted` function accepts a second optional parameter: the "scope" of the right. - - - ```php #[Query] #[Security("is_granted('POST_SHOW', post)")] @@ -140,37 +65,12 @@ public function getPost(Post $post): array } ``` - - - -```php -/** - * @Query - * @Security("is_granted('POST_SHOW', post)") - */ -public function getPost(Post $post): array -{ - // ... -} -``` - - - - In the example above, the `getPost` method can be called only if the logged user has the 'POST_SHOW' permission on the `$post` object. You can notice that the `$post` object comes from the parameters. ## Accessing method parameters -All parameters passed to the method can be accessed in the `@Security` expression. - - - +All parameters passed to the method can be accessed in the `#[Security]` expression. ```php #[Query] @@ -181,38 +81,12 @@ public function getPosts(DateTimeImmutable $startDate, DateTimeImmutable $endDat } ``` - - - -```php -/** - * @Query - * @Security("startDate < endDate", statusCode=400, message="End date must be after start date") - */ -public function getPosts(DateTimeImmutable $startDate, DateTimeImmutable $endDate): array -{ - // ... -} -``` - - - - - -In the example above, we tweak a bit the Security annotation purpose to do simple input validation. +In the example above, we tweak a bit the Security attribute purpose to do simple input validation. ## Setting HTTP code and error message You can use the `statusCode` and `message` attributes to set the HTTP code and GraphQL error message. - - - ```php #[Query] #[Security(expression: "is_granted('POST_SHOW', post)", statusCode: 404, message: "Post not found (let's pretend the post does not exists!)")] @@ -222,23 +96,6 @@ public function getPost(Post $post): array } ``` - - - -```php -/** - * @Query - * @Security("is_granted('POST_SHOW', post)", statusCode=404, message="Post not found (let's pretend the post does not exists!)") - */ -public function getPost(Post $post): array -{ - // ... -} -``` - - - - Note: since a single GraphQL call contain many errors, 2 errors might have conflicting HTTP status code. The resulting status code is up to the GraphQL middleware you use. Most of the time, the status code with the higher error code will be returned. @@ -248,14 +105,6 @@ higher error code will be returned. If you do not want an error to be thrown when the security condition is not met, you can use the `failWith` attribute to set a default value. - - - ```php #[Query] #[Security(expression: "is_granted('CAN_SEE_MARGIN', this)", failWith: null)] @@ -265,25 +114,8 @@ public function getMargin(): float } ``` - - - -```php -/** - * @Field - * @Security("is_granted('CAN_SEE_MARGIN', this)", failWith=null) - */ -public function getMargin(): float -{ - // ... -} -``` - - - - -The `failWith` attribute behaves just like the [`@FailWith` annotation](authentication-authorization.mdx#not-throwing-errors) -but for a given `@Security` annotation. +The `failWith` attribute behaves just like the [`#[FailWith]` attribute](authentication-authorization.mdx#not-throwing-errors) +but for a given `#[Security]` attribute. You cannot use the `failWith` attribute along `statusCode` or `message` attributes. @@ -292,13 +124,6 @@ You cannot use the `failWith` attribute along `statusCode` or `message` attribut You can use the `user` variable to access the currently logged user. You can use the `is_logged()` function to check if a user is logged or not. - - ```php #[Query] @@ -309,35 +134,10 @@ public function getNSFWImages(): array } ``` - - - -```php -/** - * @Query - * @Security("is_logged() && user.age > 18") - */ -public function getNSFWImages(): array -{ - // ... -} -``` - - - - ## Accessing the current object You can use the `this` variable to access any (public) property / method of the current class. - - - ```php class Post { #[Field] @@ -354,61 +154,19 @@ class Post { } ``` - - - -```php -class Post { - /** - * @Field - * @Security("this.canAccessBody(user)") - */ - public function getBody(): array - { - // ... - } - - public function canAccessBody(User $user): bool - { - // Some custom logic here - } -} -``` - - - - ## Available scope -The `@Security` annotation can be used in any query, mutation or field, so anywhere you have a `@Query`, `@Mutation` -or `@Field` annotation. +The `#[Security]` attribute can be used in any query, mutation or field, so anywhere you have a `#[Query]`, `#[Mutation]` +or `#[Field]` attribute. ## How to restrict access to a given resource The `is_granted` method can be used to restrict access to a specific resource. - - - ```php #[Security("is_granted('POST_SHOW', post)")] ``` - - - -```php -@Security("is_granted('POST_SHOW', post)") -``` - - - - If you are wondering how to configure these fine-grained permissions, this is not something that GraphQLite handles itself. Instead, this depends on the framework you are using. diff --git a/website/docs/implementing-security.md b/website/docs/implementing-security.md index ce3c8fb43f..88bbd8f7f2 100644 --- a/website/docs/implementing-security.md +++ b/website/docs/implementing-security.md @@ -50,8 +50,8 @@ You need to write classes that implement these interfaces. Then, you must regist It you are [using the `SchemaFactory`](other-frameworks.mdx), you can register your classes using: ```php -// Configure an authentication service (to resolve the @Logged annotations). +// Configure an authentication service (to resolve the #[Logged] attribute). $schemaFactory->setAuthenticationService($myAuthenticationService); -// Configure an authorization service (to resolve the @Right annotations). +// Configure an authorization service (to resolve the #[Right] attribute). $schemaFactory->setAuthorizationService($myAuthorizationService); ``` diff --git a/website/docs/inheritance-interfaces.mdx b/website/docs/inheritance-interfaces.mdx index 05f0404a0e..5467492c52 100644 --- a/website/docs/inheritance-interfaces.mdx +++ b/website/docs/inheritance-interfaces.mdx @@ -4,23 +4,12 @@ title: Inheritance and interfaces sidebar_label: Inheritance and interfaces --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - ## Modeling inheritance Some of your entities may extend other entities. GraphQLite will do its best to represent this hierarchy of objects in GraphQL using interfaces. Let's say you have two classes, `Contact` and `User` (which extends `Contact`): - - - ```php #[Type] class Contact @@ -35,40 +24,8 @@ class User extends Contact } ``` - - - -```php -/** - * @Type - */ -class Contact -{ - // ... -} - -/** - * @Type - */ -class User extends Contact -{ - // ... -} -``` - - - - Now, let's assume you have a query that returns a contact: - - - ```php class ContactController { @@ -80,25 +37,6 @@ class ContactController } ``` - - - -```php -class ContactController -{ - /** - * @Query() - */ - public function getContact(): Contact - { - // ... - } -} -``` - - - - When writing your GraphQL query, you are able to use fragments to retrieve fields from the `User` type: ```graphql @@ -135,15 +73,7 @@ available in the `Contact` type. ## Mapping interfaces -If you want to create a pure GraphQL interface, you can also add a `@Type` annotation on a PHP interface. - - - +If you want to create a pure GraphQL interface, you can also add a `#[Type]` attribute on a PHP interface. ```php #[Type] @@ -154,25 +84,6 @@ interface UserInterface } ``` - - - -```php -/** - * @Type - */ -interface UserInterface -{ - /** - * @Field - */ - public function getUserName(): string; -} -``` - - - - This will automatically create a GraphQL interface whose description is: ```graphql @@ -186,14 +97,6 @@ interface UserInterface { You don't have to do anything special to implement an interface in your GraphQL types. Simply "implement" the interface in PHP and you are done! - - - ```php #[Type] class User implements UserInterface @@ -202,22 +105,6 @@ class User implements UserInterface } ``` - - - -```php -/** - * @Type - */ -class User implements UserInterface -{ - public function getUserName(): string; -} -``` - - - - This will translate in GraphQL schema as: ```graphql @@ -230,21 +117,13 @@ type User implements UserInterface { } ``` -Please note that you do not need to put the `@Field` annotation again in the implementing class. +Please note that you do not need to put the `#[Field]` attribute again in the implementing class. ### Interfaces without an explicit implementing type -You don't have to explicitly put a `@Type` annotation on the class implementing the interface (though this +You don't have to explicitly put a `#[Type]` attribute on the class implementing the interface (though this is usually a good idea). - - - ```php /** * Look, this class has no #Type attribute @@ -266,39 +145,10 @@ class UserController } ``` - - - -```php -/** - * Look, this class has no @Type annotation - */ -class User implements UserInterface -{ - public function getUserName(): string; -} -``` - -```php -class UserController -{ - /** - * @Query() - */ - public function getUser(): UserInterface // This will work! - { - // ... - } -} -``` - - - -
If GraphQLite cannot find a proper GraphQL Object type implementing an interface, it will create an object type "on the fly".
-In the example above, because the `User` class has no `@Type` annotations, GraphQLite will +In the example above, because the `User` class has no `#[Type]` attribute, GraphQLite will create a `UserImpl` type that implements `UserInterface`. ```graphql diff --git a/website/docs/input-types.mdx b/website/docs/input-types.mdx index f2c62afd40..c0efa59e87 100644 --- a/website/docs/input-types.mdx +++ b/website/docs/input-types.mdx @@ -4,21 +4,10 @@ title: Input types sidebar_label: Input types --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - Let's assume you are developing an API that returns a list of cities around a location. Your GraphQL query might look like this: - - - ```php class MyController { @@ -56,49 +45,6 @@ class Location } ``` - - - -```php -class MyController -{ - /** - * @Query - * @return City[] - */ - public function getCities(Location $location, float $radius): array - { - // Some code that returns an array of cities. - } -} - -// Class Location is a simple value-object. -class Location -{ - private $latitude; - private $longitude; - - public function __construct(float $latitude, float $longitude) - { - $this->latitude = $latitude; - $this->longitude = $longitude; - } - - public function getLatitude(): float - { - return $this->latitude; - } - - public function getLongitude(): float - { - return $this->longitude; - } -} -``` - - - - If you try to run this code, you will get the following error: ``` @@ -115,14 +61,6 @@ There are two ways for declaring that type, in GraphQLite: using the [`#[Input]` Using the `#[Input]` attribute, we can transform the `Location` class, in the example above, into an input type. Just add the `#[Field]` attribute to the corresponding properties: - - - ```php #[Input] class Location @@ -160,88 +98,25 @@ class Location } ``` - - - -```php -/** - * @Input - */ -class Location -{ - - /** - * @Field - * @var string|null - */ - private ?string $name = null; - - /** - * @Field - * @var float - */ - private $latitude; - - /** - * @Field - * @var float - */ - private $longitude; - - public function __construct(float $latitude, float $longitude) - { - $this->latitude = $latitude; - $this->longitude = $longitude; - } - - public function setName(string $name): void - { - $this->name = $name; - } - - public function getLatitude(): float - { - return $this->latitude; - } - - public function getLongitude(): float - { - return $this->longitude; - } -} -``` - - - - Now if you call the `getCities` query, from the controller in the first example, the `Location` object will be automatically instantiated with the user provided, `latitude` / `longitude` properties, and passed to the controller as a parameter. There are some important things to notice: -- The `@Field` annotation is recognized on properties for Input Type, as well as setters. +- The `#[Field]` attribute is recognized on properties for Input Type, as well as setters. - There are 3 ways for fields to be resolved: - Via constructor if corresponding properties are mentioned as parameters with the same names - exactly as in the example above. - If properties are public, they will be just set without any additional effort - no constructor required. - - For private or protected properties implemented, a public setter is required (if they are not set via the constructor). For example `setLatitude(float $latitude)`. You can also put the `@Field` annotation on the setter, instead of the property, allowing you to have use many other attributes (`Security`, `Right`, `Autowire`, etc.). + - For private or protected properties implemented, a public setter is required (if they are not set via the constructor). For example `setLatitude(float $latitude)`. You can also put the `#[Field]` attribute on the setter, instead of the property, allowing you to have use many other attributes (`Security`, `Right`, `Autowire`, etc.). - For validation of these Input Types, see the [Custom InputType Validation section](validation#custom-inputtype-validation). - It's advised to use the `#[Input]` attribute on DTO style input type objects and not directly on your model objects. Using it on your model objects can cause coupling in undesirable ways. ### Multiple Input Types from the same class -Simple usage of the `@Input` annotation on a class creates a GraphQL input named by class name + "Input" suffix if a class name does not end with it already. Ex. `LocationInput` for `Location` class. +Simple usage of the `#[Input]` attribute on a class creates a GraphQL input named by class name + "Input" suffix if a class name does not end with it already. Ex. `LocationInput` for `Location` class. -You can add multiple `@Input` annotations to the same class, give them different names and link different fields. +You can add multiple `#[Input]` attributed to the same class, give them different names and link different fields. Consider the following example: - - - ```php #[Input(name: 'CreateUserInput', default: true)] #[Input(name: 'UpdateUserInput', update: true)] @@ -269,53 +144,6 @@ class UserInput } ``` - - - -```php -/** - * @Input(name="CreateUserInput", default=true) - * @Input(name="UpdateUserInput", update=true) - */ -class UserInput -{ - - /** - * @Field() - * @var string - */ - public $username; - - /** - * @Field(for="CreateUserInput") - * @var string - */ - public string $email; - - /** - * @Field(for="CreateUserInput", inputType="String!") - * @Field(for="UpdateUserInput", inputType="String") - * @var string|null - */ - public $password; - - /** @var int|null */ - protected $age; - - /** - * @Field() - * @param int|null $age - */ - public function setAge(?int $age): void - { - $this->age = $age; - } -} -``` - - - - There are 2 input types added to the `UserInput` class: `CreateUserInput` and `UpdateUserInput`. A few notes: - `CreateUserInput` input will be used by default for this class. - Field `username` is created for both input types, and it is required because the property type is not nullable. @@ -335,19 +163,11 @@ A **Factory** is a method that takes in parameter all the fields of the input ty Here is an example of factory: - - - ```php class MyFactory { /** - * The Factory annotation will create automatically a LocationInput input type in GraphQL. + * The Factory attribute will create automatically a LocationInput input type in GraphQL. */ #[Factory] public function createLocation(float $latitude, float $longitude): Location @@ -357,27 +177,6 @@ class MyFactory } ``` - - - -```php -class MyFactory -{ - /** - * The Factory annotation will create automatically a LocationInput input type in GraphQL. - * - * @Factory() - */ - public function createLocation(float $latitude, float $longitude): Location - { - return new Location($latitude, $longitude); - } -} -``` - - - - and now, you can run query like this: ```graphql @@ -394,7 +193,7 @@ query { } ``` -- Factories must be declared with the **@Factory** annotation. +- Factories must be declared with the **#[Factory]** attribute. - The parameters of the factories are the field of the GraphQL input type A few important things to notice: @@ -409,14 +208,6 @@ The GraphQL input type name is derived from the return type of the factory. Given the factory below, the return type is "Location", therefore, the GraphQL input type will be named "LocationInput". - - - ```php #[Factory] public function createLocation(float $latitude, float $longitude): Location @@ -425,48 +216,12 @@ public function createLocation(float $latitude, float $longitude): Location } ``` - - - -```php -/** - * @Factory() - */ -public function createLocation(float $latitude, float $longitude): Location -{ - return new Location($latitude, $longitude); -} -``` - - - - -In case you want to override the input type name, you can use the "name" attribute of the @Factory annotation: - - - +In case you want to override the input type name, you can use the "name" attribute of the #[Factory] attribute: ```php #[Factory(name: 'MyNewInputName', default: true)] ``` - - - -```php -/** - * @Factory(name="MyNewInputName", default=true) - */ -``` - - - - Note that you need to add the "default" attribute is you want your factory to be used by default (more on this in the next chapter). @@ -475,18 +230,10 @@ to you, so there is no real reason to customize it. ### Forcing an input type -You can use the `@UseInputType` annotation to force an input type of a parameter. +You can use the `#[UseInputType]` attribute to force an input type of a parameter. Let's say you want to force a parameter to be of type "ID", you can use this: - - - ```php #[Factory] #[UseInputType(for: "$id", inputType:"ID!")] @@ -496,41 +243,16 @@ public function getProductById(string $id): Product } ``` - - - -```php -/** - * @Factory() - * @UseInputType(for="$id", inputType="ID!") - */ -public function getProductById(string $id): Product -{ - return $this->productRepository->findById($id); -} -``` - - - - ### Declaring several input types for the same PHP class Available in GraphQLite 4.0+ There are situations where a given PHP class might use one factory or another depending on the context. This is often the case when your objects map database entities. -In these cases, you can use combine the use of `@UseInputType` and `@Factory` annotation to achieve your goal. +In these cases, you can use combine the use of `#[UseInputType]` and `#[Factory]` attribute to achieve your goal. Here is an annotated sample: - - - ```php /** * This class contains 2 factories to create Product objects. @@ -584,66 +306,6 @@ class ProductController } ``` - - - -```php -/** - * This class contains 2 factories to create Product objects. - * The "getProduct" method is used by default to map "Product" classes. - * The "createProduct" method will generate another input type named "CreateProductInput" - */ -class ProductFactory -{ - // ... - - /** - * This factory will be used by default to map "Product" classes. - * @Factory(name="ProductRefInput", default=true) - */ - public function getProduct(string $id): Product - { - return $this->productRepository->findById($id); - } - /** - * We specify a name for this input type explicitly. - * @Factory(name="CreateProductInput", default=false) - */ - public function createProduct(string $name, string $type): Product - { - return new Product($name, $type); - } -} - -class ProductController -{ - /** - * The "createProduct" factory will be used for this mutation. - * - * @Mutation - * @UseInputType(for="$product", inputType="CreateProductInput!") - */ - public function saveProduct(Product $product): Product - { - // ... - } - - /** - * The default "getProduct" factory will be used for this query. - * - * @Query - * @return Color[] - */ - public function availableColors(Product $product): array - { - // ... - } -} -``` - - - - ### Ignoring some parameters Available in GraphQLite 4.0+ @@ -654,14 +316,6 @@ Image your `getProductById` has an additional `lazyLoad` parameter. This paramet directly the function in PHP because you can have some level of optimisation on your code. But it is not something that you want to expose in the GraphQL API. Let's hide it! - - - ```php #[Factory] public function getProductById( @@ -674,23 +328,6 @@ public function getProductById( } ``` - - - -```php -/** - * @Factory() - * @HideParameter(for="$lazyLoad") - */ -public function getProductById(string $id, bool $lazyLoad = true): Product -{ - return $this->productRepository->findById($id, $lazyLoad); -} -``` - - - - -With the `@HideParameter` annotation, you can choose to remove from the GraphQL schema any argument. +With the `#[HideParameter]` attribute, you can choose to remove from the GraphQL schema any argument. To be able to hide an argument, the argument must have a default value. diff --git a/website/docs/internals.md b/website/docs/internals.md index 65624fcc49..93c7e958b8 100644 --- a/website/docs/internals.md +++ b/website/docs/internals.md @@ -92,7 +92,7 @@ Class type mappers are mapping PHP classes to GraphQL object types. GraphQLite provide 3 default implementations: - `CompositeTypeMapper`: a type mapper that delegates mapping to other type mappers using the Composite Design Pattern. -- `GlobTypeMapper`: scans classes in a directory for the `@Type` or `@ExtendType` annotation and maps those to GraphQL types +- `GlobTypeMapper`: scans classes in a directory for the `#[Type]` or `#[ExtendType]` attribute and maps those to GraphQL types - `PorpaginasTypeMapper`: maps and class implementing the Porpaginas `Result` interface to a [special paginated type](pagination.mdx). ### Registering a type mapper in Symfony @@ -124,9 +124,9 @@ Let's have a look at a simple query: ```php /** - * @Query * @return Product[] */ +#[Query] public function products(ResolveInfo $info): array ``` diff --git a/website/docs/laravel-package-advanced.mdx b/website/docs/laravel-package-advanced.mdx index 4f63bedcf8..f4f9918427 100644 --- a/website/docs/laravel-package-advanced.mdx +++ b/website/docs/laravel-package-advanced.mdx @@ -4,9 +4,6 @@ title: "Laravel package: advanced usage" sidebar_label: Laravel specific features --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -
Be advised! This documentation will be removed in a future release. For current and up-to-date Laravel extension specific documentation, please see the Github repository.
@@ -15,17 +12,9 @@ The Laravel package comes with a number of features to ease the integration of G ## Support for Laravel validation rules -The GraphQLite Laravel package comes with a special `@Validate` annotation to use Laravel validation rules in your +The GraphQLite Laravel package comes with a special `#[Validate]` attribute to use Laravel validation rules in your input types. - - - ```php use TheCodingMachine\GraphQLite\Laravel\Annotations\Validate; @@ -44,30 +33,7 @@ class MyController } ``` - - - -```php -use TheCodingMachine\GraphQLite\Laravel\Annotations\Validate; - -class MyController -{ - /** - * @Mutation - * @Validate(for="$email", rule="email|unique:users") - * @Validate(for="$password", rule="gte:8") - */ - public function createUser(string $email, string $password): User - { - // ... - } -} -``` - - - - -You can use the `@Validate` annotation in any query / mutation / field / factory / decorator. +You can use the `#[Validate]` attribute in any query / mutation / field / factory / decorator. If a validation fails to pass, the message will be printed in the "errors" section and you will get a HTTP 400 status code: @@ -99,14 +65,6 @@ You can use any validation rule described in [the Laravel documentation](https:/ In your query, if you explicitly return an object that extends the `Illuminate\Pagination\LengthAwarePaginator` class, the query result will be wrapped in a "paginator" type. - - - ```php class MyController { @@ -121,27 +79,6 @@ class MyController } ``` - - - -```php -class MyController -{ - /** - * @Query - * @return Product[] - */ - public function products(): Illuminate\Pagination\LengthAwarePaginator - { - return Product::paginate(15); - } -} -``` - - - - - Notice that: - the method return type MUST BE `Illuminate\Pagination\LengthAwarePaginator` or a class extending `Illuminate\Pagination\LengthAwarePaginator` @@ -177,14 +114,6 @@ products { Note: if you are using `simplePaginate` instead of `paginate`, you can type hint on the `Illuminate\Pagination\Paginator` class. - - - ```php class MyController { @@ -199,46 +128,18 @@ class MyController } ``` - - - -```php -class MyController -{ - /** - * @Query - * @return Product[] - */ - public function products(): Illuminate\Pagination\Paginator - { - return Product::simplePaginate(15); - } -} -``` - - - - The behaviour will be exactly the same except you will be missing the `totalCount` and `lastPage` fields. ## Using GraphQLite with Eloquent efficiently -In GraphQLite, you are supposed to put a `@Field` annotation on each getter. +In GraphQLite, you are supposed to put a `#[Field]` attribute on each getter. Eloquent uses PHP magic properties to expose your database records. Because Eloquent relies on magic properties, it is quite rare for an Eloquent model to have proper getters and setters. -So we need to find a workaround. GraphQLite comes with a `@MagicField` annotation to help you +So we need to find a workaround. GraphQLite comes with a `#[MagicField]` attribute to help you working with magic properties. - - - ```php #[Type] #[MagicField(name: "id", outputType: "ID!")] @@ -249,24 +150,6 @@ class Product extends Model } ``` - - - -```php -/** - * @Type() - * @MagicField(name="id", outputType="ID!") - * @MagicField(name="name", phpType="string") - * @MagicField(name="categories", phpType="Category[]") - */ -class Product extends Model -{ -} -``` - - - - Please note that since the properties are "magic", they don't have a type. Therefore, you need to pass either the "outputType" attribute with the GraphQL type matching the property, or the "phpType" attribute with the PHP type matching the property. @@ -288,7 +171,7 @@ class User extends Model } ``` -It would be tempting to put a `@Field` annotation on the `phone()` method, but this will not work. Indeed, +It would be tempting to put a `#[Field]` attribute on the `phone()` method, but this will not work. Indeed, the `phone()` method does not return a `App\Phone` object. It is the `phone` magic property that returns it. In short: @@ -299,9 +182,8 @@ In short: ```php class User extends Model { - /** - * @Field - */ + + #[Field] public function phone() { return $this->hasOne('App\Phone'); @@ -315,9 +197,7 @@ class User extends Model This works: ```php -/** - * @MagicField(name="phone", phpType="App\\Phone") - */ +#[MagicField(name: "phone", phpType:"App\\Phone")] class User extends Model { public function phone() diff --git a/website/docs/migrating.md b/website/docs/migrating.md index aa181f7fcc..2753878265 100644 --- a/website/docs/migrating.md +++ b/website/docs/migrating.md @@ -21,14 +21,14 @@ $ composer require ecodev/graphql-upload If you are a "regular" GraphQLite user, migration to v4 should be straightforward: -- Annotations are mostly untouched. The only annotation that is changed is the `@SourceField` annotation. - - Check your code for every places where you use the `@SourceField` annotation: +- Annotations are mostly untouched. The only annotation that is changed is the `#[SourceField]` annotation. + - Check your code for every places where you use the `#[SourceField]` annotation: - The "id" attribute has been remove (`@SourceField(id=true)`). Instead, use `@SourceField(outputType="ID")` - The "logged", "right" and "failWith" attributes have been removed (`@SourceField(logged=true)`). - Instead, use the annotations attribute with the same annotations you use for the `@Field` annotation: + Instead, use the annotations attribute with the same annotations you use for the `#[Field]` annotation: `@SourceField(annotations={@Logged, @FailWith(null)})` - - If you use magic property and were creating a getter for every magic property (to put a `@Field` annotation on it), - you can now replace this getter with a `@MagicField` annotation. + - If you use magic property and were creating a getter for every magic property (to put a `#[Field]` annotation on it), + you can now replace this getter with a `#[MagicField]` annotation. - In GraphQLite v3, the default was to hide a field from the schema if a user has no access to it. In GraphQLite v4, the default is to still show this field, but to throw an error if the user makes a query on it (this way, the schema is the same for all users). If you want the old mode, use the new diff --git a/website/docs/multiple-output-types.mdx b/website/docs/multiple-output-types.mdx index c404c9a5b6..2f0d5b1e8f 100644 --- a/website/docs/multiple-output-types.mdx +++ b/website/docs/multiple-output-types.mdx @@ -4,9 +4,6 @@ title: Mapping multiple output types for the same class sidebar_label: Class with multiple output types --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - Available in GraphQLite 4.0+ In most cases, you have one PHP class and you want to map it to one GraphQL output type. @@ -14,23 +11,13 @@ In most cases, you have one PHP class and you want to map it to one GraphQL outp But in very specific cases, you may want to use different GraphQL output type for the same class. For instance, depending on the context, you might want to prevent the user from accessing some fields of your object. -To do so, you need to create 2 output types for the same PHP class. You typically do this using the "default" attribute of the `@Type` annotation. +To do so, you need to create 2 output types for the same PHP class. You typically do this using the "default" attribute of the `#[Type]` attribute. ## Example Here is an example. Say we are manipulating products. When I query a `Product` details, I want to have access to all fields. But for some reason, I don't want to expose the price field of a product if I query the list of all products. - - - - - ```php #[Type] class Product @@ -51,51 +38,8 @@ class Product } ``` - - - - - -```php -/** - * @Type() - */ -class Product -{ - // ... - - /** - * @Field() - */ - public function getName(): string - { - return $this->name; - } - - /** - * @Field() - */ - public function getPrice(): ?float - { - return $this->price; - } -} -``` - - - - - The `Product` class is declaring a classic GraphQL output type named "Product". - - - ```php #[Type(class: Product::class, name: "LimitedProduct", default: false)] #[SourceField(name: "name")] @@ -105,27 +49,8 @@ class LimitedProductType } ``` - - - -```php -/** - * @Type(class=Product::class, name="LimitedProduct", default=false) - * @SourceField(name="name") - */ -class LimitedProductType -{ - // ... -} -``` - - - - - - The `LimitedProductType` also declares an ["external" type](external-type-declaration.mdx) mapping the `Product` class. -But pay special attention to the `@Type` annotation. +But pay special attention to the `#[Type]` attribute. First of all, we specify `name="LimitedProduct"`. This is useful to avoid having colliding names with the "Product" GraphQL output type that is already declared. @@ -135,14 +60,6 @@ This type will only be used when we explicitly request it. Finally, we can write our requests: - - - ```php class ProductController { @@ -162,35 +79,7 @@ class ProductController } ``` - - - -```php -class ProductController -{ - /** - * This field will use the default type. - * - * @Field - */ - public function getProduct(int $id): Product { /* ... */ } - - /** - * Because we use the "outputType" attribute, this field will use the other type. - * - * @Field(outputType="[LimitedProduct!]!") - * @return Product[] - */ - public function getProducts(): array { /* ... */ } -} -``` - - - - - - -Notice how the "outputType" attribute is used in the `@Field` annotation to force the output type. +Notice how the "outputType" attribute is used in the `#[Field]` attribute to force the output type. Is a result, when the end user calls the `product` query, we will have the possibility to fetch the `name` and `price` fields, but if he calls the `products` query, each product in the list will have a `name` field but no `price` field. We managed @@ -198,59 +87,19 @@ to successfully expose a different set of fields based on the query context. ## Extending a non-default type -If you want to extend a type using the `@ExtendType` annotation and if this type is declared as non-default, +If you want to extend a type using the `#[ExtendType]` attribute and if this type is declared as non-default, you need to target the type by name instead of by class. So instead of writing: - - - ```php #[ExtendType(class: Product::class)] ``` - - - -```php -/** - * @ExtendType(class=Product::class) - */ -``` - - - - you will write: - - - ```php #[ExtendType(name: "LimitedProduct")] ``` - - - -```php -/** - * @ExtendType(name="LimitedProduct") - */ -``` - - - - -Notice how we use the "name" attribute instead of the "class" attribute in the `@ExtendType` annotation. +Notice how we use the "name" attribute instead of the "class" attribute in the `#[ExtendType]` attribute. diff --git a/website/docs/mutations.mdx b/website/docs/mutations.mdx index 16ab03a72a..614705e6f6 100644 --- a/website/docs/mutations.mdx +++ b/website/docs/mutations.mdx @@ -4,23 +4,12 @@ title: Mutations sidebar_label: Mutations --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - In GraphQLite, mutations are created [like queries](queries.mdx). -To create a mutation, you must annotate a method in a controller with the `@Mutation` annotation. +To create a mutation, you must annotate a method in a controller with the `#[Mutation]` attribute. For instance: - - - ```php namespace App\Controller; @@ -36,25 +25,3 @@ class ProductController } ``` - - - -```php -namespace App\Controller; - -use TheCodingMachine\GraphQLite\Annotations\Mutation; - -class ProductController -{ - /** - * @Mutation - */ - public function saveProduct(int $id, string $name, ?float $price = null): Product - { - // Some code that saves a product. - } -} -``` - - - diff --git a/website/docs/other-frameworks.mdx b/website/docs/other-frameworks.mdx index 4957449cc7..2bd411b0e1 100644 --- a/website/docs/other-frameworks.mdx +++ b/website/docs/other-frameworks.mdx @@ -4,9 +4,6 @@ title: Getting started with any framework sidebar_label: "Other frameworks / No framework" --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - ## Installation Open a terminal in your current project directory and run: @@ -50,9 +47,9 @@ or the [StandardServer class](https://webonyx.github.io/graphql-php/executing-qu The `SchemaFactory` class also comes with a number of methods that you can use to customize your GraphQLite settings. ```php -// Configure an authentication service (to resolve the @Logged annotations). +// Configure an authentication service (to resolve the #[Logged] attributes). $factory->setAuthenticationService(new VoidAuthenticationService()); -// Configure an authorization service (to resolve the @Right annotations). +// Configure an authorization service (to resolve the #[Right] attributes). $factory->setAuthorizationService(new VoidAuthorizationService()); // Change the naming convention of GraphQL types globally. $factory->setNamingStrategy(new NamingStrategy()); @@ -302,15 +299,6 @@ The application will look into the `App\Controllers` namespace for GraphQLite co It assumes that the container has an entry whose name is the controller's fully qualified class name. - - - - ```php title="src/Controllers/MyController.php" namespace App\Controllers; @@ -326,30 +314,6 @@ class MyController } ``` - - - -```php title="src/Controllers/MyController.php" -namespace App\Controllers; - -use TheCodingMachine\GraphQLite\Annotations\Query; - -class MyController -{ - /** - * @Query - */ - public function hello(string $name): string - { - return 'Hello '.$name; - } -} -``` - - - - - ```php title="config/container.php" use App\Controllers\MyController; diff --git a/website/docs/pagination.mdx b/website/docs/pagination.mdx index 66b58c863b..44ad427c53 100644 --- a/website/docs/pagination.mdx +++ b/website/docs/pagination.mdx @@ -4,9 +4,6 @@ title: Paginating large result sets sidebar_label: Pagination --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - It is quite common to have to paginate over large result sets. GraphQLite offers a simple way to do that using [Porpaginas](https://github.com/beberlei/porpaginas). @@ -29,14 +26,6 @@ $ composer require beberlei/porpaginas In your query, simply return a class that implements `Porpaginas\Result`: - - - ```php class MyController { @@ -54,29 +43,6 @@ class MyController } ``` - - - -```php -class MyController -{ - /** - * @Query - * @return Product[] - */ - public function products(): Porpaginas\Result - { - // Some code that returns a list of products - - // If you are using Doctrine, something like: - return new Porpaginas\Doctrine\ORM\ORMQueryResult($doctrineQuery); - } -} -``` - - - - Notice that: - the method return type MUST BE `Porpaginas\Result` or a class implementing `Porpaginas\Result` diff --git a/website/docs/prefetch-method.mdx b/website/docs/prefetch-method.mdx index 1c24e7099f..a12cb50891 100644 --- a/website/docs/prefetch-method.mdx +++ b/website/docs/prefetch-method.mdx @@ -4,9 +4,6 @@ title: Prefetching records sidebar_label: Prefetching records --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - ## The problem GraphQL naive implementations often suffer from the "N+1" problem. @@ -43,14 +40,6 @@ Instead, GraphQLite offers an easier to implement solution: the ability to fetch ## The "prefetch" method - - - ```php #[Type] class PostType { @@ -80,45 +69,7 @@ class PostType { } ``` - - - -```php -/** - * @Type - */ -class PostType { - /** - * @Field(prefetchMethod="prefetchUsers") - * @param mixed $prefetchedUsers - * @return User - */ - public function getUser($prefetchedUsers): User - { - // This method will receive the $prefetchedUsers as first argument. This is the return value of the "prefetchUsers" method below. - // Using this prefetched list, it should be easy to map it to the post - } - - /** - * @param Post[] $posts - * @return mixed - */ - public function prefetchUsers(iterable $posts) - { - // This function is called only once per GraphQL request - // with the list of posts. You can fetch the list of users - // associated with this posts in a single request, - // for instance using a "IN" query in SQL or a multi-fetch - // in your cache back-end. - } -} -``` - - - - - -When a "#[Prefetch]" attribute is detected on a parameter of "@Field" annotation, the method is called automatically. +When a `#[Prefetch]` attribute is detected on a parameter of `#[Field]` attribute, the method is called automatically. The prefetch callable must be one of the following: - a static method in the same class: `#[Prefetch('prefetchMethod')]` - a static method in a different class: `#[Prefetch([OtherClass::class, 'prefetchMethod')]` @@ -127,18 +78,10 @@ The first argument of the method is always an array of instances of the main typ ## Input arguments -Field arguments can be set either on the @Field annotated method OR/AND on the prefetch methods. +Field arguments can be set either on the `#[Field]` annotated method OR/AND on the prefetch methods. For instance: - - - ```php #[Type] class PostType { @@ -163,36 +106,3 @@ class PostType { } } ``` - - - - -```php -/** - * @Type - */ -class PostType { - /** - * @Field(prefetchMethod="prefetchComments") - * @param mixed $prefetchedComments - * @return Comment[] - */ - public function getComments($prefetchedComments): array - { - // ... - } - - /** - * @param Post[] $posts - * @return mixed - */ - public function prefetchComments(iterable $posts, bool $hideSpam, int $filterByScore) - { - // Parameters passed after the first parameter (hideSpam, filterByScore...) are automatically exposed - // as GraphQL arguments for the "comments" field. - } -} -``` - - - diff --git a/website/docs/queries.mdx b/website/docs/queries.mdx index 34abab54dd..138e4812eb 100644 --- a/website/docs/queries.mdx +++ b/website/docs/queries.mdx @@ -4,9 +4,6 @@ title: Queries sidebar_label: Queries --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - In GraphQLite, GraphQL queries are created by writing methods in *controller* classes. Those classes must be in the controllers namespaces which has been defined when you configured GraphQLite. @@ -14,15 +11,7 @@ For instance, in Symfony, the controllers namespace is `App\Controller` by defau ## Simple query -In a controller class, each query method must be annotated with the `@Query` annotation. For instance: - - - +In a controller class, each query method must be annotated with the `#[Query]` attribute. For instance: ```php namespace App\Controller; @@ -39,29 +28,6 @@ class MyController } ``` - - - -```php -namespace App\Controller; - -use TheCodingMachine\GraphQLite\Annotations\Query; - -class MyController -{ - /** - * @Query - */ - public function hello(string $name): string - { - return 'Hello ' . $name; - } -} -``` - - - - This query is equivalent to the following [GraphQL type language](https://graphql.org/learn/schema/#type-language): ```graphql @@ -74,13 +40,11 @@ As you can see, GraphQLite will automatically do the mapping between PHP types a
Heads up! If you are not using a framework with an autowiring container (like Symfony or Laravel), please be aware that the MyController class must exist in the container of your application. Furthermore, the identifier of the controller in the container MUST be the fully qualified class name of controller.
-## About annotations / attributes +## About attributes -GraphQLite relies a lot on annotations (we call them attributes since PHP 8). +GraphQLite relies a lot on attributes. -It supports both the old "Doctrine annotations" style (`@Query`) and the new PHP 8 attributes (`#[Query]`). - -Read the [Doctrine annotations VS attributes](doctrine-annotations-attributes.mdx) documentation if you are not familiar with this concept. +It supports the new PHP 8 attributes (`#[Query]`), the "Doctrine annotations" style (`#[Query]`) was dropped. ## Testing the query @@ -104,14 +68,6 @@ So far, we simply declared a query. But we did not yet declare a type. Let's assume you want to return a product: - - - ```php namespace App\Controller; @@ -127,41 +83,9 @@ class ProductController } ``` - - - -```php -namespace App\Controller; - -use TheCodingMachine\GraphQLite\Annotations\Query; - -class ProductController -{ - /** - * @Query - */ - public function product(string $id): Product - { - // Some code that looks for a product and returns it. - } -} -``` - - - - - As the `Product` class is not a scalar type, you must tell GraphQLite how to handle it: - - - ```php namespace App\Entities; @@ -187,46 +111,9 @@ class Product } ``` - - - -```php -namespace App\Entities; - -use TheCodingMachine\GraphQLite\Annotations\Field; -use TheCodingMachine\GraphQLite\Annotations\Type; - -/** - * @Type() - */ -class Product -{ - // ... - - /** - * @Field() - */ - public function getName(): string - { - return $this->name; - } - - /** - * @Field() - */ - public function getPrice(): ?float - { - return $this->price; - } -} -``` - - - - -The `@Type` annotation is used to inform GraphQLite that the `Product` class is a GraphQL type. +The `#[Type]` attribute is used to inform GraphQLite that the `Product` class is a GraphQL type. -The `@Field` annotation is used to define the GraphQL fields. This annotation must be put on a **public method**. +The `#[Field]` attribute is used to define the GraphQL fields. This attribute must be put on a **public method**. The `Product` class must be in one of the *types* namespaces. As for *controller* classes, you configured this namespace when you installed GraphQLite. By default, in Symfony, the allowed types namespaces are `App\Entity` and `App\Types`. @@ -243,9 +130,9 @@ Type Product {

If you are used to Domain driven design, you probably realize that the Product class is part of your domain.

-

GraphQL annotations are adding some serialization logic that is out of scope of the domain. - These are just annotations and for most project, this is the fastest and easiest route.

-

If you feel that GraphQL annotations do not belong to the domain, or if you cannot modify the class +

GraphQL attributes are adding some serialization logic that is out of scope of the domain. + These are just attributes and for most project, this is the fastest and easiest route.

+

If you feel that GraphQL attributes do not belong to the domain, or if you cannot modify the class directly (maybe because it is part of a third party library), there is another way to create types without annotating the domain class. We will explore that in the next chapter.

diff --git a/website/docs/query-plan.mdx b/website/docs/query-plan.mdx index 98838399d0..094b77c55e 100644 --- a/website/docs/query-plan.mdx +++ b/website/docs/query-plan.mdx @@ -4,9 +4,6 @@ title: Query plan sidebar_label: Query plan --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - ## The problem GraphQL naive implementations often suffer from the "N+1" problem. @@ -43,14 +40,6 @@ With GraphQLite, you can answer this question by tapping into the `ResolveInfo` Available in GraphQLite 4.0+ - - - ```php use GraphQL\Type\Definition\ResolveInfo; @@ -72,34 +61,6 @@ class ProductsController } ``` - - - -```php -use GraphQL\Type\Definition\ResolveInfo; - -class ProductsController -{ - /** - * @Query - * @return Product[] - */ - public function products(ResolveInfo $info): array - { - if (isset($info->getFieldSelection()['manufacturer']) { - // Let's perform a request with a JOIN on manufacturer - } else { - // Let's perform a request without a JOIN on manufacturer - } - // ... - } -} -``` - - - - - `ResolveInfo` is a class provided by Webonyx/GraphQL-PHP (the low-level GraphQL library used by GraphQLite). It contains info about the query and what fields are requested. Using `ResolveInfo::getFieldSelection` you can analyze the query and decide whether you should perform additional "JOINS" in your query or not. diff --git a/website/docs/symfony-bundle-advanced.mdx b/website/docs/symfony-bundle-advanced.mdx index d96b9fae68..c34c6d7628 100644 --- a/website/docs/symfony-bundle-advanced.mdx +++ b/website/docs/symfony-bundle-advanced.mdx @@ -4,9 +4,6 @@ title: "Symfony bundle: advanced usage" sidebar_label: Symfony specific features --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -
Be advised! This documentation will be removed in a future release. For current and up-to-date Symfony bundle specific documentation, please see the Github repository.
@@ -114,15 +111,7 @@ This interface is automatically mapped to a type with 2 fields: - `userName: String!` - `roles: [String!]!` -If you want to get more fields, just add the `@Type` annotation to your user class: - - - +If you want to get more fields, just add the `#[Type]` attribute to your user class: ```php #[Type] @@ -137,29 +126,6 @@ class User implements UserInterface } ``` - - - -```php -/** - * @Type - */ -class User implements UserInterface -{ - /** - * @Field - */ - public function getEmail() : string - { - // ... - } - -} -``` - - - - You can now query this field using an [inline fragment](https://graphql.org/learn/queries/#inline-fragments): ```graphql @@ -192,14 +158,6 @@ Most of the time, getting the request object is irrelevant. Indeed, it is GraphQ manage it for you. Sometimes yet, fetching the request can be needed. In those cases, simply type-hint on the request in any parameter of your query/mutation/field. - - - ```php use Symfony\Component\HttpFoundation\Request; @@ -208,22 +166,4 @@ public function getUser(int $id, Request $request): User { // The $request object contains the Symfony Request. } -``` - - - - -```php -use Symfony\Component\HttpFoundation\Request; - -/** - * @Query - */ -public function getUser(int $id, Request $request): User -{ - // The $request object contains the Symfony Request. -} -``` - - - +``` \ No newline at end of file diff --git a/website/docs/type-mapping.mdx b/website/docs/type-mapping.mdx index 2051dd9559..26127719d6 100644 --- a/website/docs/type-mapping.mdx +++ b/website/docs/type-mapping.mdx @@ -4,9 +4,6 @@ title: Type mapping sidebar_label: Type mapping --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - As explained in the [queries](queries.mdx) section, the job of GraphQLite is to create GraphQL types from PHP types. ## Scalar mapping @@ -20,14 +17,6 @@ Scalar PHP types can be type-hinted to the corresponding GraphQL types: For instance: - - - ```php namespace App\Controller; @@ -43,40 +32,9 @@ class MyController } ``` - - - -```php -namespace App\Controller; - -use TheCodingMachine\GraphQLite\Annotations\Query; - -class MyController -{ - /** - * @Query - */ - public function hello(string $name): string - { - return 'Hello ' . $name; - } -} -``` - - - - ## Class mapping -When returning a PHP class in a query, you must annotate this class using `@Type` and `@Field` annotations: - - - +When returning a PHP class in a query, you must annotate this class using `#[Type]` and `#[Field]` attributes: ```php namespace App\Entities; @@ -103,90 +61,24 @@ class Product } ``` - - - -```php -namespace App\Entities; - -use TheCodingMachine\GraphQLite\Annotations\Field; -use TheCodingMachine\GraphQLite\Annotations\Type; - -/** - * @Type() - */ -class Product -{ - // ... - - /** - * @Field() - */ - public function getName(): string - { - return $this->name; - } - - /** - * @Field() - */ - public function getPrice(): ?float - { - return $this->price; - } -} -``` - - - - **Note:** The GraphQL output type name generated by GraphQLite is equal to the class name of the PHP class. So if your PHP class is `App\Entities\Product`, then the GraphQL type will be named "Product". In case you have several types with the same class name in different namespaces, you will face a naming collision. Hopefully, you can force the name of the GraphQL output type using the "name" attribute: - - - ```php #[Type(name: "MyProduct")] class Product { /* ... */ } ``` - - - -```php -/** - * @Type(name="MyProduct") - */ -class Product { /* ... */ } -``` - - - - -
You can also put a @Type annotation on a PHP interface + ## Array mapping You can type-hint against arrays (or iterators) as long as you add a detailed `@return` statement in the PHPDoc. - - - ```php /** * @return User[] <=== we specify that the array is an array of User objects. @@ -198,23 +90,6 @@ public function users(int $limit, int $offset): array } ``` - - - -```php -/** - * @Query - * @return User[] <=== we specify that the array is an array of User objects. - */ -public function users(int $limit, int $offset): array -{ - // Some code that returns an array of "users". -} -``` - - - - ## ID mapping GraphQL comes with a native `ID` type. PHP has no such type. @@ -223,14 +98,6 @@ There are two ways with GraphQLite to handle such type. ### Force the outputType - - - ```php #[Field(outputType: "ID")] public function getId(): string @@ -239,36 +106,12 @@ public function getId(): string } ``` - - - -```php -/** - * @Field(outputType="ID") - */ -public function getId(): string -{ - // ... -} -``` - - - - -Using the `outputType` attribute of the `@Field` annotation, you can force the output type to `ID`. +Using the `outputType` attribute of the `#[Field]` attribute, you can force the output type to `ID`. You can learn more about forcing output types in the [custom types section](custom-types.mdx). ### ID class - - - ```php use TheCodingMachine\GraphQLite\Types\ID; @@ -279,34 +122,8 @@ public function getId(): ID } ``` - - - -```php -use TheCodingMachine\GraphQLite\Types\ID; - -/** - * @Field - */ -public function getId(): ID -{ - // ... -} -``` - - - - Note that you can also use the `ID` class as an input type: - - - ```php use TheCodingMachine\GraphQLite\Types\ID; @@ -317,24 +134,6 @@ public function save(ID $id, string $name): Product } ``` - - - -```php -use TheCodingMachine\GraphQLite\Types\ID; - -/** - * @Mutation - */ -public function save(ID $id, string $name): Product -{ - // ... -} -``` - - - - ## Date mapping Out of the box, GraphQL does not have a `DateTime` type, but we took the liberty to add one, with sensible defaults. @@ -342,14 +141,6 @@ Out of the box, GraphQL does not have a `DateTime` type, but we took the liberty When used as an output type, `DateTimeImmutable` or `DateTimeInterface` PHP classes are automatically mapped to this `DateTime` GraphQL type. - - - ```php #[Field] public function getDate(): \DateTimeInterface @@ -358,22 +149,6 @@ public function getDate(): \DateTimeInterface } ``` - - - -```php -/** - * @Field - */ -public function getDate(): \DateTimeInterface -{ - return $this->date; -} -``` - - - - The `date` field will be of type `DateTime`. In the returned JSON response to a query, the date is formatted as a string in the **ISO8601** format (aka ATOM format). @@ -385,14 +160,6 @@ in the **ISO8601** format (aka ATOM format). Union types for return are supported in GraphQLite as of version 6.0: - - - ```php #[Query] public function companyOrContact(int $id): Company|Contact @@ -401,23 +168,6 @@ public function companyOrContact(int $id): Company|Contact } ``` - - - -```php -/** - * @Query - * @return Company|Contact - */ -public function companyOrContact(int $id) -{ - // Some code that returns a company or a contact. -} -``` - - - - ## Enum types PHP 8.1 introduced native support for Enums. GraphQLite now also supports native enums as of version 5.1. @@ -455,7 +205,7 @@ query users($status: Status!) {} ``` By default, the name of the GraphQL enum type will be the name of the class. If you have a naming conflict (two classes -that live in different namespaces with the same class name), you can solve it using the `name` property on the `@Type` annotation: +that live in different namespaces with the same class name), you can solve it using the `name` property on the `#[Type]` attribute: ```php namespace Model\User; @@ -467,7 +217,6 @@ enum Status: string } ``` - ### Enum types with myclabs/php-enum
@@ -482,14 +231,6 @@ $ composer require myclabs/php-enum Now, any class extending the `MyCLabs\Enum\Enum` class will be mapped to a GraphQL enum: - - - ```php use MyCLabs\Enum\Enum; @@ -517,39 +258,6 @@ public function users(StatusEnum $status): array } ``` - - - -```php -use MyCLabs\Enum\Enum; - -class StatusEnum extends Enum -{ - private const ON = 'on'; - private const OFF = 'off'; - private const PENDING = 'pending'; -} -``` - -```php -/** - * @Query - * @return User[] - */ -public function users(StatusEnum $status): array -{ - if ($status == StatusEnum::ON()) { - // Note that the "magic" ON() method returns an instance of the StatusEnum class. - // Also, note that we are comparing this instance using "==" (using "===" would fail as we have 2 different instances here) - // ... - } - // ... -} -``` - - - - ```graphql query users($status: StatusEnum!) {} users(status: $status) { @@ -559,15 +267,7 @@ query users($status: StatusEnum!) {} ``` By default, the name of the GraphQL enum type will be the name of the class. If you have a naming conflict (two classes -that live in different namespaces with the same class name), you can solve it using the `@EnumType` annotation: - - - +that live in different namespaces with the same class name), you can solve it using the `#[EnumType]` attribute: ```php use TheCodingMachine\GraphQLite\Annotations\EnumType; @@ -579,24 +279,6 @@ class StatusEnum extends Enum } ``` - - - -```php -use TheCodingMachine\GraphQLite\Annotations\EnumType; - -/** - * @EnumType(name="UserStatus") - */ -class StatusEnum extends Enum -{ - // ... -} -``` - - - -
GraphQLite must be able to find all the classes extending the "MyCLabs\Enum" class in your project. By default, GraphQLite will look for "Enum" classes in the namespaces declared for the types. For this reason, your enum classes MUST be in one of the namespaces declared for the types in your GraphQLite @@ -612,25 +294,21 @@ namespace App\Entities; use TheCodingMachine\GraphQLite\Annotations\Field; use TheCodingMachine\GraphQLite\Annotations\Type; -/** - * @Type() - */ +#[Type] class Product { // ... - /** - * @Field() - */ + #[Field] public function getName(): string { return $this->name; } /** - * @Field() * @deprecated use field `name` instead */ + #[Field] public function getProductName(): string { return $this->name; diff --git a/website/docs/validation.mdx b/website/docs/validation.mdx index dfb2c925dd..3b8ac86ac2 100644 --- a/website/docs/validation.mdx +++ b/website/docs/validation.mdx @@ -4,9 +4,6 @@ title: Validation sidebar_label: User input validation --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - GraphQLite does not handle user input validation by itself. It is out of its scope. However, it can integrate with your favorite framework validation mechanism. The way you validate user input will @@ -31,18 +28,9 @@ GraphQLite provides a bridge to use the [Symfony validator](https://symfony.com/ ### Using the Symfony validator bridge -Usually, when you use the Symfony validator component, you put annotations in your entities and you validate those entities +Usually, when you use the Symfony validator component, you put attributes in your entities and you validate those entities using the `Validator` object. - - - ```php title="UserController.php" use Symfony\Component\Validator\Validator\ValidatorInterface; use TheCodingMachine\GraphQLite\Validator\ValidationFailedException @@ -73,55 +61,8 @@ class UserController } ``` - - - -```php title="UserController.php" -use Symfony\Component\Validator\Validator\ValidatorInterface; -use TheCodingMachine\GraphQLite\Validator\ValidationFailedException - -class UserController -{ - private $validator; - - public function __construct(ValidatorInterface $validator) - { - $this->validator = $validator; - } - - /** - * @Mutation - */ - public function createUser(string $email, string $password): User - { - $user = new User($email, $password); - - // Let's validate the user - $errors = $this->validator->validate($user); - - // Throw an appropriate GraphQL exception if validation errors are encountered - ValidationFailedException::throwException($errors); - - // No errors? Let's continue and save the user - // ... - } -} -``` - - - - Validation rules are added directly to the object in the domain model: - - - ```php title="User.php" use Symfony\Component\Validator\Constraints as Assert; @@ -146,41 +87,6 @@ class User } ``` - - - -```php title="User.php" -use Symfony\Component\Validator\Constraints as Assert; - -class User -{ - /** - * @Assert\Email( - * message = "The email '{{ value }}' is not a valid email.", - * checkMX = true - * ) - */ - private $email; - - /** - * The NotCompromisedPassword assertion asks the "HaveIBeenPawned" service if your password has already leaked or not. - * @Assert\NotCompromisedPassword - */ - private $password; - - public function __construct(string $email, string $password) - { - $this->email = $email; - $this->password = $password; - } - - // ... -} -``` - - - - If a validation fails, GraphQLite will return the failed validations in the "errors" section of the JSON response: ```json @@ -212,35 +118,32 @@ on your domain objects. Only use this technique if you want to validate user inp in a domain object.
-Use the `@Assertion` annotation to validate directly the user input. +Use the `#[Assertion]` attribute to validate directly the user input. ```php use Symfony\Component\Validator\Constraints as Assert; use TheCodingMachine\GraphQLite\Validator\Annotations\Assertion; +use TheCodingMachine\GraphQLite\Annotations\Query; -/** - * @Query - * @Assertion(for="email", constraint=@Assert\Email()) - */ +#[Query] +#[Assertion(for: "email", constraint: new Assert\Email())] public function findByMail(string $email): User { // ... } ``` -Notice that the "constraint" parameter contains an annotation (it is an annotation wrapped in an annotation). +Notice that the "constraint" parameter contains an attribute (it is an attribute wrapped in an attribute). You can also pass an array to the `constraint` parameter: ```php -@Assertion(for="email", constraint={@Assert\NotBlank(), @Assert\Email()}) +#[Assertion(for: "email", constraint: [new Assert\NotBlack(), new Assert\Email()])] ``` -
Heads up! The "@Assertion" annotation is only available as a Doctrine annotations. You cannot use it as a PHP 8 attributes
- ## Custom InputType Validation -GraphQLite also supports a fully custom validation implementation for all input types defined with an `@Input` annotation or PHP8 `#[Input]` attribute. This offers a way to validate input types before they're available as a method parameter of your query and mutation controllers. This way, when you're using your query or mutation controllers, you can feel confident that your input type objects have already been validated. +GraphQLite also supports a fully custom validation implementation for all input types defined with an `#[Input]` attribute. This offers a way to validate input types before they're available as a method parameter of your query and mutation controllers. This way, when you're using your query or mutation controllers, you can feel confident that your input type objects have already been validated.

It's important to note that this validation implementation does not validate input types created with a factory. If you are creating an input type with a factory, or using primitive parameters in your query/mutation controllers, you should be sure to validate these independently. This is strictly for input type objects.

@@ -248,7 +151,7 @@ GraphQLite also supports a fully custom validation implementation for all input

You can use one of the framework validation libraries listed above or implement your own validation for these cases. If you're using input type objects for most all of your query and mutation controllers, then there is little additional validation concerns with regards to user input. There are many reasons why you should consider defaulting to an InputType object, as opposed to individual arguments, for your queries and mutations. This is just one additional perk.

-To get started with validation on input types defined by an `@Input` annotation, you'll first need to register your validator with the `SchemaFactory`. +To get started with validation on input types defined by an `#[Input]` attribute, you'll first need to register your validator with the `SchemaFactory`. ```php $factory = new SchemaFactory($cache, $this->container); @@ -278,7 +181,7 @@ interface InputTypeValidatorInterface } ``` -The interface is quite simple. Handle all of your own validation logic in the `validate` method. For example, you might use Symfony's annotation based validation in addition to some other custom validation logic. It's really up to you on how you wish to handle your own validation. The `validate` method will receive the input type object populated with the user input. +The interface is quite simple. Handle all of your own validation logic in the `validate` method. For example, you might use Symfony's attribute based validation in addition to some other custom validation logic. It's really up to you on how you wish to handle your own validation. The `validate` method will receive the input type object populated with the user input. You'll notice that the `validate` method has a `void` return. The purpose here is to encourage you to throw an Exception or handle validation output however you best see fit. GraphQLite does it's best to stay out of your way and doesn't make attempts to handle validation output. You can, however, throw an instance of `TheCodingMachine\GraphQLite\Exceptions\GraphQLException` or `TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException` as usual (see [Error Handling](error-handling) for more details). From d75d12a137980ec639b859e74d1dcb1239de00fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Apr 2024 22:31:20 -0400 Subject: [PATCH 070/108] Bump codecov/codecov-action from 4.2.0 to 4.3.0 (#681) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.2.0 to 4.3.0. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v4.2.0...v4.3.0) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 02fa936a45..6d2f287629 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -84,7 +84,7 @@ jobs: path: "build" overwrite: true - - uses: codecov/codecov-action@v4.2.0 # upload the coverage to codecov + - uses: codecov/codecov-action@v4.3.0 # upload the coverage to codecov with: fail_ci_if_error: false # optional (default = false) - Need CODECOV_TOKEN # Do not upload in forks, and only on php8.3, latest deps From 1a8dd391fc909ac0d64c93a023731f655f1866c3 Mon Sep 17 00:00:00 2001 From: Shish Date: Fri, 19 Apr 2024 04:08:26 +0100 Subject: [PATCH 071/108] Add "no framework" example to examples folder (#668) Having an example as actual code rather than as out-of-context snippets inside a text file means that we can run the code, and ensure that it actually works. --- .github/workflows/continuous_integration.yml | 30 ++++++++++++++ .gitignore | 3 ++ examples/no-framework/README.md | 11 +++++ examples/no-framework/composer.json | 28 +++++++++++++ examples/no-framework/index.php | 41 +++++++++++++++++++ .../src/Controllers/MyController.php | 14 +++++++ 6 files changed, 127 insertions(+) create mode 100644 examples/no-framework/README.md create mode 100644 examples/no-framework/composer.json create mode 100644 examples/no-framework/index.php create mode 100644 examples/no-framework/src/Controllers/MyController.php diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 6d2f287629..7edf4cb5b8 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -89,3 +89,33 @@ jobs: fail_ci_if_error: false # optional (default = false) - Need CODECOV_TOKEN # Do not upload in forks, and only on php8.3, latest deps if: ${{ github.repository == 'thecodingmachine/graphqlite' && matrix.php-version == '8.3' && matrix.install-args == '' }} + + examples: + name: Check Examples + runs-on: ubuntu-latest + strategy: + matrix: + example: ['no-framework'] + fail-fast: false + steps: + - name: "Checkout" + uses: "actions/checkout@v4" + - name: "Install PHP with extensions" + uses: "shivammathur/setup-php@v2" + with: + php-version: "8.2" + tools: composer:v2 + - name: "Install dependencies with composer" + working-directory: "examples/${{ matrix.example }}" + run: "composer --version && composer install --no-interaction --no-progress --prefer-dist" + - name: "Run example ${{ matrix.example }}" + working-directory: "examples/${{ matrix.example }}" + run: | + php -S localhost:8080 & + sleep 3 + curl --silent -X POST -H "Content-Type: application/json" \ + -d '{"query":"{ hello(name: \"World\") }"}' \ + http://localhost:8080/graphql -o output.json + grep -q '"data":{"hello":"Hello World"}' output.json || \ + (cat output.json && false) + kill %1 diff --git a/.gitignore b/.gitignore index e35760b84d..45f3ed8be5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ /.php_cs/cache /.idea +examples/*/vendor/ +examples/*/composer.lock + node_modules lib/core/metadata.js diff --git a/examples/no-framework/README.md b/examples/no-framework/README.md new file mode 100644 index 0000000000..470576554f --- /dev/null +++ b/examples/no-framework/README.md @@ -0,0 +1,11 @@ +No-Framework Integration Example +================================ + +``` +composer install +php -S 127.0.0.1:8080 +``` + +``` +curl -X POST -d '{"query":"{ hello(name: \"World\") }"}' -H "Content-Type: application/json" http://localhost:8080/ +``` diff --git a/examples/no-framework/composer.json b/examples/no-framework/composer.json new file mode 100644 index 0000000000..91c4da4444 --- /dev/null +++ b/examples/no-framework/composer.json @@ -0,0 +1,28 @@ +{ + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "require": { + "thecodingmachine/graphqlite": "@dev", + "mouf/picotainer": "^1.1", + "symfony/cache": "^4.2" + }, + "repositories": [ + { + "type": "path", + "url": "tmp-graphqlite", + "options": { + "symlink": true + } + } + ], + "scripts": { + "symlink-package": [ + "rm -rf tmp-graphqlite && ln -s -f ../../ tmp-graphqlite" + ], + "pre-install-cmd": "@symlink-package", + "pre-update-cmd": "@symlink-package" + } +} diff --git a/examples/no-framework/index.php b/examples/no-framework/index.php new file mode 100644 index 0000000000..356c631175 --- /dev/null +++ b/examples/no-framework/index.php @@ -0,0 +1,41 @@ + function() { + return new MyController(); + }, +]); + +$factory = new SchemaFactory($cache, $container); +$factory->addControllerNamespace('App\\Controllers') + ->addTypeNamespace('App'); + +$schema = $factory->createSchema(); + +$rawInput = file_get_contents('php://input'); +$input = json_decode($rawInput, true); +$query = $input['query']; +$variableValues = isset($input['variables']) ? $input['variables'] : null; + +$result = GraphQL::executeQuery($schema, $query, null, new Context(), $variableValues); +$output = $result->toArray(); + +header('Content-Type: application/json'); +echo json_encode($output) . "\n"; + diff --git a/examples/no-framework/src/Controllers/MyController.php b/examples/no-framework/src/Controllers/MyController.php new file mode 100644 index 0000000000..3557ff4ce6 --- /dev/null +++ b/examples/no-framework/src/Controllers/MyController.php @@ -0,0 +1,14 @@ + Date: Fri, 10 May 2024 01:15:07 +0200 Subject: [PATCH 072/108] [Error handling] Remove category from exceptions (#685) * Remove category from exceptions * Remove category from tests * Remove category from docs --- src/Exceptions/GraphQLException.php | 11 ----- .../PorpaginasMissingParameterException.php | 10 ----- .../MissingAuthorizationException.php | 10 ----- src/Parameters/MissingArgumentException.php | 10 ----- tests/Exceptions/ErrorHandlerTest.php | 4 +- tests/Http/HttpCodeDeciderTest.php | 5 --- .../MissingArgumentExceptionTest.php | 1 - website/docs/error-handling.mdx | 40 +------------------ website/docs/laravel-package-advanced.mdx | 6 +-- website/docs/validation.mdx | 3 +- 10 files changed, 6 insertions(+), 94 deletions(-) diff --git a/src/Exceptions/GraphQLException.php b/src/Exceptions/GraphQLException.php index c874291a5c..c6afc58a4c 100644 --- a/src/Exceptions/GraphQLException.php +++ b/src/Exceptions/GraphQLException.php @@ -14,7 +14,6 @@ public function __construct( string $message, int $code = 0, Throwable|null $previous = null, - protected string $category = 'Exception', protected array $extensions = [], ) { parent::__construct($message, $code, $previous); @@ -28,16 +27,6 @@ public function isClientSafe(): bool return true; } - /** - * Returns string describing a category of the error. - * - * Value "graphql" is reserved for errors produced by query parsing or validation, do not use it. - */ - public function getCategory(): string - { - return $this->category; - } - /** * Returns the "extensions" object attached to the GraphQL error. * diff --git a/src/Mappers/PorpaginasMissingParameterException.php b/src/Mappers/PorpaginasMissingParameterException.php index 1af433520f..afb40e377d 100644 --- a/src/Mappers/PorpaginasMissingParameterException.php +++ b/src/Mappers/PorpaginasMissingParameterException.php @@ -28,14 +28,4 @@ public function isClientSafe(): bool { return true; } - - /** - * Returns string describing a category of the error. - * - * Value "graphql" is reserved for errors produced by query parsing or validation, do not use it. - */ - public function getCategory(): string - { - return 'pagination'; - } } diff --git a/src/Middlewares/MissingAuthorizationException.php b/src/Middlewares/MissingAuthorizationException.php index e04edd2d6d..4b9a4758c6 100644 --- a/src/Middlewares/MissingAuthorizationException.php +++ b/src/Middlewares/MissingAuthorizationException.php @@ -26,14 +26,4 @@ public function isClientSafe(): bool { return true; } - - /** - * Returns string describing a category of the error. - * - * Value "graphql" is reserved for errors produced by query parsing or validation, do not use it. - */ - public function getCategory(): string - { - return 'security'; - } } diff --git a/src/Parameters/MissingArgumentException.php b/src/Parameters/MissingArgumentException.php index be48cd5564..d906f8063f 100644 --- a/src/Parameters/MissingArgumentException.php +++ b/src/Parameters/MissingArgumentException.php @@ -81,16 +81,6 @@ public function isClientSafe(): bool return true; } - /** - * Returns string describing a category of the error. - * - * Value "graphql" is reserved for errors produced by query parsing or validation, do not use it. - */ - public function getCategory(): string - { - return 'graphql'; - } - /** * Returns the "extensions" object attached to the GraphQL error. * diff --git a/tests/Exceptions/ErrorHandlerTest.php b/tests/Exceptions/ErrorHandlerTest.php index 637499ec57..9c20b86d38 100644 --- a/tests/Exceptions/ErrorHandlerTest.php +++ b/tests/Exceptions/ErrorHandlerTest.php @@ -10,7 +10,7 @@ class ErrorHandlerTest extends TestCase public function testErrorFormatter() { - $exception = new GraphQLException('foo', 0, null, 'MyCategory', ['field' => 'foo']); + $exception = new GraphQLException('foo', 0, null, ['field' => 'foo']); $error = new Error('foo', null, null, [], null, $exception); $formattedError = WebonyxErrorHandler::errorFormatter($error); $this->assertSame([ @@ -23,7 +23,7 @@ public function testErrorFormatter() public function testErrorHandler() { - $exception = new GraphQLException('foo', 0, null, 'MyCategory', ['field' => 'foo']); + $exception = new GraphQLException('foo', 0, null, ['field' => 'foo']); $error = new Error('bar', null, null, [], null, $exception); $aggregateException = new GraphQLAggregateException(); $aggregateException->add($exception); diff --git a/tests/Http/HttpCodeDeciderTest.php b/tests/Http/HttpCodeDeciderTest.php index dd82364c74..d7a0629da5 100644 --- a/tests/Http/HttpCodeDeciderTest.php +++ b/tests/Http/HttpCodeDeciderTest.php @@ -38,11 +38,6 @@ public function isClientSafe():bool { return true; } - - public function getCategory() - { - return 'foo'; - } }; $clientAwareError = new Error('Foo', null, null, [], null, $clientAwareException); diff --git a/tests/Parameters/MissingArgumentExceptionTest.php b/tests/Parameters/MissingArgumentExceptionTest.php index 9c0ba460f0..c4b7eec3dc 100644 --- a/tests/Parameters/MissingArgumentExceptionTest.php +++ b/tests/Parameters/MissingArgumentExceptionTest.php @@ -21,6 +21,5 @@ public function testWrapWithFactoryContext(): void $this->assertTrue($e->isClientSafe()); $this->assertSame([], $e->getExtensions()); - $this->assertSame('graphql', $e->getCategory()); } } diff --git a/website/docs/error-handling.mdx b/website/docs/error-handling.mdx index 549a9c15c9..1fe32a54de 100644 --- a/website/docs/error-handling.mdx +++ b/website/docs/error-handling.mdx @@ -12,10 +12,7 @@ In GraphQL, when an error occurs, the server must add an "error" entry in the re { "message": "Name for character with ID 1002 could not be fetched.", "locations": [ { "line": 6, "column": 7 } ], - "path": [ "hero", "heroFriends", 1, "name" ], - "extensions": { - "category": "Exception" - } + "path": [ "hero", "heroFriends", 1, "name" ] } ] } @@ -43,30 +40,6 @@ throw new GraphQLException("Not found", 404);
GraphQL allows to have several errors for one request. If you have several GraphQLException thrown for the same request, the HTTP status code used will be the highest one.
-## Customizing the category - -By default, GraphQLite adds a "category" entry in the "extensions section". You can customize the category with the -4th parameter of the constructor: - -```php -throw new GraphQLException("Not found", 404, null, "NOT_FOUND"); -``` - -will generate: - -```json -{ - "errors": [ - { - "message": "Not found", - "extensions": { - "category": "NOT_FOUND" - } - } - ] -} -``` - ## Customizing the extensions section You can customize the whole "extensions" section with the 5th parameter of the constructor: @@ -83,7 +56,6 @@ will generate: { "message": "Field required", "extensions": { - "category": "VALIDATION", "field": "name" } } @@ -109,16 +81,6 @@ class ValidationException extends Exception implements GraphQLExceptionInterface return true; } - /** - * Returns string describing a category of the error. - * - * Value "graphql" is reserved for errors produced by query parsing or validation, do not use it. - */ - public function getCategory(): string - { - return 'VALIDATION'; - } - /** * Returns the "extensions" object attached to the GraphQL error. * diff --git a/website/docs/laravel-package-advanced.mdx b/website/docs/laravel-package-advanced.mdx index f4f9918427..3341538023 100644 --- a/website/docs/laravel-package-advanced.mdx +++ b/website/docs/laravel-package-advanced.mdx @@ -43,15 +43,13 @@ If a validation fails to pass, the message will be printed in the "errors" secti { "message": "The email must be a valid email address.", "extensions": { - "argument": "email", - "category": "Validate" + "argument": "email" } }, { "message": "The password must be greater than or equal 8 characters.", "extensions": { - "argument": "password", - "category": "Validate" + "argument": "password" } } ] diff --git a/website/docs/validation.mdx b/website/docs/validation.mdx index 3b8ac86ac2..13a05188fd 100644 --- a/website/docs/validation.mdx +++ b/website/docs/validation.mdx @@ -96,8 +96,7 @@ If a validation fails, GraphQLite will return the failed validations in the "err "message": "The email '\"foo@thisdomaindoesnotexistatall.com\"' is not a valid email.", "extensions": { "code": "bf447c1c-0266-4e10-9c6c-573df282e413", - "field": "email", - "category": "Validate" + "field": "email" } } ] From f4cf32c042ae1dd605a602e414971e7f11316679 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Jun 2024 17:14:42 -0400 Subject: [PATCH 073/108] Bump codecov/codecov-action from 4.3.0 to 4.5.0 (#692) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.3.0 to 4.5.0. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v4.3.0...v4.5.0) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 7edf4cb5b8..6f68e1304a 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -84,7 +84,7 @@ jobs: path: "build" overwrite: true - - uses: codecov/codecov-action@v4.3.0 # upload the coverage to codecov + - uses: codecov/codecov-action@v4.5.0 # upload the coverage to codecov with: fail_ci_if_error: false # optional (default = false) - Need CODECOV_TOKEN # Do not upload in forks, and only on php8.3, latest deps From 81094e99b13a2bf8aad71c9c8d54cd836e34bc96 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Jun 2024 17:15:06 -0400 Subject: [PATCH 074/108] --- (#690) updated-dependencies: - dependency-name: JamesIves/github-pages-deploy-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/doc_generation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/doc_generation.yml b/.github/workflows/doc_generation.yml index 4bf4397551..e2851c911b 100644 --- a/.github/workflows/doc_generation.yml +++ b/.github/workflows/doc_generation.yml @@ -36,7 +36,7 @@ jobs: - name: "Deploy website" if: "${{ github.event_name == 'push' || github.event_name == 'release' }}" - uses: JamesIves/github-pages-deploy-action@v4.5.0 + uses: JamesIves/github-pages-deploy-action@v4.6.1 with: token: ${{ secrets.GITHUB_TOKEN }} branch: gh-pages # The branch the action should deploy to. From 069d62a69eeeb97f8559d7623ab137774863e7e0 Mon Sep 17 00:00:00 2001 From: Daniel Melchior Date: Wed, 17 Jul 2024 00:11:37 +0200 Subject: [PATCH 075/108] chore: remove dependency on thecodingmachine/cache-utils (#695) * chore: remove dependency on thecodingmachine/cache-utils The package seems to be unmaintained and prevents usage due to old psr/simple-cache. This removes the dependency on that package. The package provided cache clearance on Classes and ParentClasses when their files changed. This functionality can still be achieved by creating a custom implementation of `TheCodingMachine\GraphQLite\Utils\Cache\ClassBoundCacheContractFactoryInterface`. fixes thecodingmachine/graphqlite#693 * fix: correctly escape `|` in phpstan ignoreErrors * fix: sync min symfony/cache version of library to example and solve deprecation This is necessary because symfony/cache is not compatible with every supported version of psr/simple-cache. More info here: https://github.com/symfony/symfony/issues/44738 --- composer.json | 1 - examples/no-framework/composer.json | 3 +- examples/no-framework/index.php | 5 ++- phpstan.neon | 2 +- src/Cache/ClassBoundCacheContract.php | 43 +++++++++++++++++++ src/Cache/ClassBoundCacheContractFactory.php | 15 +++++++ ...lassBoundCacheContractFactoryInterface.php | 12 ++++++ .../ClassBoundCacheContractInterface.php | 13 ++++++ src/FactoryContext.php | 7 +++ src/Mappers/AbstractTypeMapper.php | 26 ++++------- src/Mappers/GlobTypeMapper.php | 3 ++ src/Mappers/StaticClassListTypeMapper.php | 3 ++ .../StaticClassListTypeMapperFactory.php | 1 + src/SchemaFactory.php | 17 +++++++- tests/FactoryContextTest.php | 5 ++- 15 files changed, 131 insertions(+), 25 deletions(-) create mode 100644 src/Cache/ClassBoundCacheContract.php create mode 100644 src/Cache/ClassBoundCacheContractFactory.php create mode 100644 src/Cache/ClassBoundCacheContractFactoryInterface.php create mode 100644 src/Cache/ClassBoundCacheContractInterface.php diff --git a/composer.json b/composer.json index 5f89d4d2be..5b47aa1049 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,6 @@ "psr/simple-cache": "^1.0.1 || ^2 || ^3", "symfony/cache": "^4.3 || ^5 || ^6 || ^7", "symfony/expression-language": "^4 || ^5 || ^6 || ^7", - "thecodingmachine/cache-utils": "^1", "webonyx/graphql-php": "^v15.0", "kcs/class-finder": "^0.5.0" }, diff --git a/examples/no-framework/composer.json b/examples/no-framework/composer.json index 91c4da4444..27ea1ab713 100644 --- a/examples/no-framework/composer.json +++ b/examples/no-framework/composer.json @@ -7,7 +7,8 @@ "require": { "thecodingmachine/graphqlite": "@dev", "mouf/picotainer": "^1.1", - "symfony/cache": "^4.2" + "symfony/cache": "^4.3", + "psr/simple-cache": "^1.0" }, "repositories": [ { diff --git a/examples/no-framework/index.php b/examples/no-framework/index.php index 356c631175..b8f43ee18b 100644 --- a/examples/no-framework/index.php +++ b/examples/no-framework/index.php @@ -4,7 +4,8 @@ use TheCodingMachine\GraphQLite\SchemaFactory; use TheCodingMachine\GraphQLite\Context\Context; -use Symfony\Component\Cache\Simple\FilesystemCache; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Psr16Cache; use Mouf\Picotainer\Picotainer; use GraphQL\Utils\SchemaPrinter; use App\Controllers\MyController; @@ -12,7 +13,7 @@ require_once __DIR__ . '/vendor/autoload.php'; // $cache is any PSR-16 compatible cache. -$cache = new FilesystemCache(); +$cache = new Psr16Cache(new FilesystemAdapter());; // $container is any PSR-11 compatible container which has // been populated with your controller classes. diff --git a/phpstan.neon b/phpstan.neon index 803977c572..86c9770f50 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -34,7 +34,7 @@ parameters: message: '#Unreachable statement - code above always terminates.#' path: src/Http/WebonyxGraphqlMiddleware.php - - message: '#Property TheCodingMachine\\GraphQLite\\Annotations\\Type::\$class \(class-string\\|null\) does not accept string.#' + message: '#Property TheCodingMachine\\GraphQLite\\Annotations\\Type::\$class \(class-string\|null\) does not accept string.#' path: src/Annotations/Type.php - message: '#Method TheCodingMachine\\GraphQLite\\AnnotationReader::(getMethodAnnotations|getPropertyAnnotations)\(\) should return array but returns array.#' diff --git a/src/Cache/ClassBoundCacheContract.php b/src/Cache/ClassBoundCacheContract.php new file mode 100644 index 0000000000..3509794a19 --- /dev/null +++ b/src/Cache/ClassBoundCacheContract.php @@ -0,0 +1,43 @@ +cachePrefix = str_replace(['\\', '{', '}', '(', ')', '/', '@', ':'], '_', $cachePrefix); + } + + /** + * @param string $key An optional key to differentiate between cache items attached to the same class. + * + * @throws InvalidArgumentException + */ + public function get(ReflectionClass $reflectionClass, callable $resolver, string $key = '', int|null $ttl = null): mixed + { + $cacheKey = $reflectionClass->getName() . '__' . $key; + $cacheKey = $this->cachePrefix . str_replace(['\\', '{', '}', '(', ')', '/', '@', ':'], '_', $cacheKey); + + $item = $this->classBoundCache->get($cacheKey); + if ($item !== null) { + return $item; + } + + $item = $resolver(); + + $this->classBoundCache->set($cacheKey, $item, $ttl); + + return $item; + } +} diff --git a/src/Cache/ClassBoundCacheContractFactory.php b/src/Cache/ClassBoundCacheContractFactory.php new file mode 100644 index 0000000000..0c2dfc7750 --- /dev/null +++ b/src/Cache/ClassBoundCacheContractFactory.php @@ -0,0 +1,15 @@ +cache; } + public function getClassBoundCacheContractFactory(): ClassBoundCacheContractFactoryInterface|null + { + return $this->classBoundCacheContractFactory; + } + public function getInputTypeValidator(): InputTypeValidatorInterface|null { return $this->inputTypeValidator; diff --git a/src/Mappers/AbstractTypeMapper.php b/src/Mappers/AbstractTypeMapper.php index db3946d532..7e09f01ed5 100644 --- a/src/Mappers/AbstractTypeMapper.php +++ b/src/Mappers/AbstractTypeMapper.php @@ -15,12 +15,10 @@ use ReflectionMethod; use Symfony\Component\Cache\Adapter\Psr16Adapter; use Symfony\Contracts\Cache\CacheInterface as CacheContractInterface; -use TheCodingMachine\CacheUtils\ClassBoundCache; -use TheCodingMachine\CacheUtils\ClassBoundCacheContract; -use TheCodingMachine\CacheUtils\ClassBoundCacheContractInterface; -use TheCodingMachine\CacheUtils\ClassBoundMemoryAdapter; -use TheCodingMachine\CacheUtils\FileBoundCache; use TheCodingMachine\GraphQLite\AnnotationReader; +use TheCodingMachine\GraphQLite\Cache\ClassBoundCacheContractFactory; +use TheCodingMachine\GraphQLite\Cache\ClassBoundCacheContractFactoryInterface; +use TheCodingMachine\GraphQLite\Cache\ClassBoundCacheContractInterface; use TheCodingMachine\GraphQLite\InputTypeGenerator; use TheCodingMachine\GraphQLite\InputTypeUtils; use TheCodingMachine\GraphQLite\NamingStrategyInterface; @@ -66,23 +64,15 @@ public function __construct( private readonly CacheInterface $cache, protected int|null $globTTL = 2, private readonly int|null $mapTTL = null, + ClassBoundCacheContractFactoryInterface|null $classBoundCacheContractFactory = null, ) { $this->cacheContract = new Psr16Adapter($this->cache, $cachePrefix, $this->globTTL ?? 0); - $classToAnnotationsCache = new ClassBoundCache( - new FileBoundCache($this->cache, 'classToAnnotations_' . $cachePrefix), - ); - $this->mapClassToAnnotationsCache = new ClassBoundCacheContract( - new ClassBoundMemoryAdapter($classToAnnotationsCache), - ); - - $classToExtendedAnnotationsCache = new ClassBoundCache( - new FileBoundCache($this->cache, 'classToExtendAnnotations_' . $cachePrefix), - ); - $this->mapClassToExtendAnnotationsCache = new ClassBoundCacheContract( - new ClassBoundMemoryAdapter($classToExtendedAnnotationsCache), - ); + $classBoundCacheContractFactory = $classBoundCacheContractFactory ?? new ClassBoundCacheContractFactory(); + + $this->mapClassToAnnotationsCache = $classBoundCacheContractFactory->make($cache, 'classToAnnotations_' . $cachePrefix); + $this->mapClassToExtendAnnotationsCache = $classBoundCacheContractFactory->make($cache, 'classToExtendAnnotations_' . $cachePrefix); } /** diff --git a/src/Mappers/GlobTypeMapper.php b/src/Mappers/GlobTypeMapper.php index 132673b898..f094eb7359 100644 --- a/src/Mappers/GlobTypeMapper.php +++ b/src/Mappers/GlobTypeMapper.php @@ -8,6 +8,7 @@ use Psr\SimpleCache\CacheInterface; use ReflectionClass; use TheCodingMachine\GraphQLite\AnnotationReader; +use TheCodingMachine\GraphQLite\Cache\ClassBoundCacheContractFactoryInterface; use TheCodingMachine\GraphQLite\InputTypeGenerator; use TheCodingMachine\GraphQLite\InputTypeUtils; use TheCodingMachine\GraphQLite\NamingStrategyInterface; @@ -44,6 +45,7 @@ public function __construct( CacheInterface $cache, int|null $globTTL = 2, int|null $mapTTL = null, + ClassBoundCacheContractFactoryInterface|null $classBoundCacheContractFactory = null, ) { $cachePrefix = str_replace( ['\\', '{', '}', '(', ')', '/', '@', ':'], @@ -63,6 +65,7 @@ public function __construct( $cache, $globTTL, $mapTTL, + $classBoundCacheContractFactory, ); } diff --git a/src/Mappers/StaticClassListTypeMapper.php b/src/Mappers/StaticClassListTypeMapper.php index eec76c3097..f5fb0139b0 100644 --- a/src/Mappers/StaticClassListTypeMapper.php +++ b/src/Mappers/StaticClassListTypeMapper.php @@ -8,6 +8,7 @@ use Psr\SimpleCache\CacheInterface; use ReflectionClass; use TheCodingMachine\GraphQLite\AnnotationReader; +use TheCodingMachine\GraphQLite\Cache\ClassBoundCacheContractFactoryInterface; use TheCodingMachine\GraphQLite\GraphQLRuntimeException; use TheCodingMachine\GraphQLite\InputTypeGenerator; use TheCodingMachine\GraphQLite\InputTypeUtils; @@ -45,6 +46,7 @@ public function __construct( CacheInterface $cache, int|null $globTTL = 2, int|null $mapTTL = null, + ClassBoundCacheContractFactoryInterface|null $classBoundCacheContractFactory = null, ) { $cachePrefix = str_replace( ['\\', '{', '}', '(', ')', '/', '@', ':'], @@ -64,6 +66,7 @@ public function __construct( $cache, $globTTL, $mapTTL, + $classBoundCacheContractFactory, ); } diff --git a/src/Mappers/StaticClassListTypeMapperFactory.php b/src/Mappers/StaticClassListTypeMapperFactory.php index ff8becbc6f..774c59c2b7 100644 --- a/src/Mappers/StaticClassListTypeMapperFactory.php +++ b/src/Mappers/StaticClassListTypeMapperFactory.php @@ -38,6 +38,7 @@ public function create(FactoryContext $context): TypeMapperInterface $context->getCache(), $context->getGlobTTL(), $context->getMapTTL(), + $context->getClassBoundCacheContractFactory(), ); } } diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index a4c2bd2c6d..029c5eada8 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -13,6 +13,7 @@ use Psr\SimpleCache\CacheInterface; use Symfony\Component\Cache\Adapter\Psr16Adapter; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use TheCodingMachine\GraphQLite\Cache\ClassBoundCacheContractFactoryInterface; use TheCodingMachine\GraphQLite\Mappers\CompositeTypeMapper; use TheCodingMachine\GraphQLite\Mappers\GlobTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Parameters\ContainerParameterHandler; @@ -120,7 +121,7 @@ class SchemaFactory private string $cacheNamespace; - public function __construct(private readonly CacheInterface $cache, private readonly ContainerInterface $container) + public function __construct(private readonly CacheInterface $cache, private readonly ContainerInterface $container, private ClassBoundCacheContractFactoryInterface|null $classBoundCacheContractFactory = null) { $this->cacheNamespace = substr(md5(Versions::getVersion('thecodingmachine/graphqlite')), 0, 8); } @@ -259,6 +260,18 @@ public function setGlobTTL(int|null $globTTL): self return $this; } + /** + * Set a custom ClassBoundCacheContractFactory. + * This is used to create CacheContracts that store reflection results. + * Set this to "null" to use the default fallback factory. + */ + public function setClassBoundCacheContractFactory(ClassBoundCacheContractFactoryInterface|null $classBoundCacheContractFactory): self + { + $this->classBoundCacheContractFactory = $classBoundCacheContractFactory; + + return $this; + } + /** * Sets GraphQLite in "prod" mode (cache settings optimized for best performance). * @@ -430,6 +443,7 @@ public function createSchema(): Schema $recursiveTypeMapper, $namespacedCache, $this->globTTL, + classBoundCacheContractFactory: $this->classBoundCacheContractFactory, )); } @@ -447,6 +461,7 @@ public function createSchema(): Schema $namespacedCache, $this->inputTypeValidator, $this->globTTL, + classBoundCacheContractFactory: $this->classBoundCacheContractFactory, ); } diff --git a/tests/FactoryContextTest.php b/tests/FactoryContextTest.php index fec72a6ddd..9f02729924 100644 --- a/tests/FactoryContextTest.php +++ b/tests/FactoryContextTest.php @@ -4,6 +4,7 @@ use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Psr16Cache; +use TheCodingMachine\GraphQLite\Cache\ClassBoundCacheContractFactory; use TheCodingMachine\GraphQLite\Containers\EmptyContainer; use TheCodingMachine\GraphQLite\Fixtures\Inputs\Validator; @@ -16,6 +17,7 @@ public function testContext(): void $namingStrategy = new NamingStrategy(); $container = new EmptyContainer(); $arrayCache = new Psr16Cache(new ArrayAdapter()); + $classBoundCacheContractFactory = new ClassBoundCacheContractFactory(); $validator = new Validator(); $context = new FactoryContext( @@ -30,7 +32,8 @@ public function testContext(): void $container, $arrayCache, $validator, - self::GLOB_TTL_SECONDS + self::GLOB_TTL_SECONDS, + classBoundCacheContractFactory: $classBoundCacheContractFactory, ); $this->assertSame($this->getAnnotationReader(), $context->getAnnotationReader()); From 592568c1060f3a3f600c118b69c1e7f5b5f96ed1 Mon Sep 17 00:00:00 2001 From: Oleksandr Prypkhan Date: Tue, 24 Sep 2024 06:22:36 +0300 Subject: [PATCH 076/108] Performance optimizations and caching (#698) * Combine addTypeNamespace() and addControllerNamespace() into addNamespace() * Refactor CachedDocBlockFactory to use existing caching infrastructure * Refactor field name * Refactor code to use ClassFinder instead of separating namespaces * Make caches only recompute on file changes * Refactor EnumTypeMapper to use cached doc block factory * Fix FileModificationClassFinderBoundCache * Use Kcs ClassFinder cache * One more tiny optimization for unchanged cache * Change name of ClassFinderBoundCache * Code style * PHPStan * Some fixes and renames * Fix broken class bound cache after merge * Fix some tests and PHPStan * Fix all failing tests * Fix one failing test on CI * Fix one failing test on CI, again * Fix --prefer-lowest tests * Some simplifications and tests * Simplify cached doc blocks * Simplify cached doc blocks * More tests for doc block factories * Tests for the Discovery namespace * Fix the docs build on CI and broken links * Add a changelog entry * Deprecate setGlobTTL() instead of removing it * Code style --- .github/workflows/doc_generation.yml | 2 +- composer.json | 4 +- examples/no-framework/index.php | 3 +- src/AggregateControllerQueryProvider.php | 35 ++-- src/Cache/ClassBoundCache.php | 24 +++ src/Cache/ClassBoundCacheContract.php | 43 ---- src/Cache/ClassBoundCacheContractFactory.php | 15 -- ...lassBoundCacheContractFactoryInterface.php | 12 -- .../ClassBoundCacheContractInterface.php | 13 -- src/Cache/FilesSnapshot.php | 86 ++++++++ src/Cache/SnapshotClassBoundCache.php | 41 ++++ .../Cache/ClassFinderComputedCache.php | 41 ++++ .../Cache/HardClassFinderComputedCache.php | 69 ++++++ .../SnapshotClassFinderComputedCache.php | 129 ++++++++++++ src/Discovery/ClassFinder.php | 14 ++ src/Discovery/KcsClassFinder.php | 32 +++ src/Discovery/StaticClassFinder.php | 44 ++++ src/FactoryContext.php | 26 +-- src/FieldsBuilder.php | 22 +- src/GlobControllerQueryProvider.php | 94 +++------ ...peMapper.php => ClassFinderTypeMapper.php} | 196 ++++++------------ src/Mappers/GlobAnnotationsCache.php | 6 + src/Mappers/GlobExtendAnnotationsCache.php | 2 + src/Mappers/GlobExtendTypeMapperCache.php | 6 +- src/Mappers/GlobTypeMapper.php | 82 -------- src/Mappers/GlobTypeMapperCache.php | 14 +- src/Mappers/Parameters/TypeHandler.php | 6 +- src/Mappers/Root/EnumTypeMapper.php | 72 +++---- src/Mappers/Root/MyCLabsEnumTypeMapper.php | 47 ++--- .../Root/RootTypeMapperFactoryContext.php | 28 ++- src/Mappers/StaticClassListTypeMapper.php | 93 --------- .../StaticClassListTypeMapperFactory.php | 14 +- src/Reflection/CachedDocBlockFactory.php | 133 ------------ .../DocBlock/CachedDocBlockFactory.php | 52 +++++ src/Reflection/DocBlock/DocBlockFactory.php | 25 +++ .../DocBlock/PhpDocumentorDocBlockFactory.php | 51 +++++ src/SchemaFactory.php | 193 +++++++++++------ src/Utils/Namespaces/NS.php | 104 ---------- src/Utils/Namespaces/NamespaceFactory.php | 29 --- tests/AbstractQueryProvider.php | 66 ++++-- tests/Cache/FilesSnapshotTest.php | 77 +++++++ tests/Cache/SnapshotClassBoundCacheTest.php | 58 ++++++ .../HardClassFinderComputedCacheTest.php | 58 ++++++ .../SnapshotClassFinderComputedCacheTest.php | 89 ++++++++ tests/Discovery/KcsClassFinderTest.php | 53 +++++ tests/Discovery/StaticClassFinderTest.php | 42 ++++ tests/FactoryContextTest.php | 19 +- tests/FieldsBuilderTest.php | 8 +- tests/GlobControllerQueryProviderTest.php | 9 +- .../Psr15GraphQLMiddlewareBuilderTest.php | 4 +- tests/Integration/AnnotatedInterfaceTest.php | 3 +- tests/Integration/EndToEndTest.php | 67 +----- tests/Integration/IntegrationTestCase.php | 100 +++++---- ...Test.php => ClassFinderTypeMapperTest.php} | 84 ++++---- tests/Mappers/Parameters/TypeMapperTest.php | 40 ++-- tests/Mappers/RecursiveTypeMapperTest.php | 6 +- .../Root/MyCLabsEnumTypeMapperTest.php | 2 +- .../Mappers/StaticClassListTypeMapperTest.php | 31 --- tests/Mappers/StaticTypeMapperTest.php | 2 +- .../Reflection/CachedDocBlockFactoryTest.php | 48 ----- .../DocBlock/CachedDocBlockFactoryTest.php | 62 ++++++ .../PhpDocumentorDocBlockFactoryTest.php | 47 +++++ tests/RootTypeMapperFactoryContextTest.php | 20 +- tests/SchemaFactoryTest.php | 32 +-- tests/Utils/NsTest.php | 145 ------------- website/docs/CHANGELOG.md | 18 ++ website/docs/input-types.mdx | 2 +- website/docs/other-frameworks.mdx | 12 +- website/docs/validation.mdx | 3 +- .../version-6.0/input-types.mdx | 2 +- .../version-6.1/input-types.mdx | 2 +- .../version-7.0.0/input-types.mdx | 2 +- 72 files changed, 1700 insertions(+), 1415 deletions(-) create mode 100644 src/Cache/ClassBoundCache.php delete mode 100644 src/Cache/ClassBoundCacheContract.php delete mode 100644 src/Cache/ClassBoundCacheContractFactory.php delete mode 100644 src/Cache/ClassBoundCacheContractFactoryInterface.php delete mode 100644 src/Cache/ClassBoundCacheContractInterface.php create mode 100644 src/Cache/FilesSnapshot.php create mode 100644 src/Cache/SnapshotClassBoundCache.php create mode 100644 src/Discovery/Cache/ClassFinderComputedCache.php create mode 100644 src/Discovery/Cache/HardClassFinderComputedCache.php create mode 100644 src/Discovery/Cache/SnapshotClassFinderComputedCache.php create mode 100644 src/Discovery/ClassFinder.php create mode 100644 src/Discovery/KcsClassFinder.php create mode 100644 src/Discovery/StaticClassFinder.php rename src/Mappers/{AbstractTypeMapper.php => ClassFinderTypeMapper.php} (68%) delete mode 100644 src/Mappers/GlobTypeMapper.php delete mode 100644 src/Mappers/StaticClassListTypeMapper.php delete mode 100644 src/Reflection/CachedDocBlockFactory.php create mode 100644 src/Reflection/DocBlock/CachedDocBlockFactory.php create mode 100644 src/Reflection/DocBlock/DocBlockFactory.php create mode 100644 src/Reflection/DocBlock/PhpDocumentorDocBlockFactory.php delete mode 100644 src/Utils/Namespaces/NS.php delete mode 100644 src/Utils/Namespaces/NamespaceFactory.php create mode 100644 tests/Cache/FilesSnapshotTest.php create mode 100644 tests/Cache/SnapshotClassBoundCacheTest.php create mode 100644 tests/Discovery/Cache/HardClassFinderComputedCacheTest.php create mode 100644 tests/Discovery/Cache/SnapshotClassFinderComputedCacheTest.php create mode 100644 tests/Discovery/KcsClassFinderTest.php create mode 100644 tests/Discovery/StaticClassFinderTest.php rename tests/Mappers/{GlobTypeMapperTest.php => ClassFinderTypeMapperTest.php} (65%) delete mode 100644 tests/Mappers/StaticClassListTypeMapperTest.php delete mode 100644 tests/Reflection/CachedDocBlockFactoryTest.php create mode 100644 tests/Reflection/DocBlock/CachedDocBlockFactoryTest.php create mode 100644 tests/Reflection/DocBlock/PhpDocumentorDocBlockFactoryTest.php delete mode 100644 tests/Utils/NsTest.php diff --git a/.github/workflows/doc_generation.yml b/.github/workflows/doc_generation.yml index e2851c911b..4c73a105b5 100644 --- a/.github/workflows/doc_generation.yml +++ b/.github/workflows/doc_generation.yml @@ -24,7 +24,7 @@ jobs: - name: "Setup NodeJS" uses: actions/setup-node@v4 with: - node-version: '16.x' + node-version: '20.x' - name: "Yarn install" run: yarn install diff --git a/composer.json b/composer.json index 5b47aa1049..6063ab0c19 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ "symfony/cache": "^4.3 || ^5 || ^6 || ^7", "symfony/expression-language": "^4 || ^5 || ^6 || ^7", "webonyx/graphql-php": "^v15.0", - "kcs/class-finder": "^0.5.0" + "kcs/class-finder": "^0.5.1" }, "require-dev": { "beberlei/porpaginas": "^1.2 || ^2.0", @@ -34,7 +34,7 @@ "myclabs/php-enum": "^1.6.6", "php-coveralls/php-coveralls": "^2.1", "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.9", + "phpstan/phpstan": "^1.11", "phpunit/phpunit": "^10.1 || ^11.0", "symfony/var-dumper": "^5.4 || ^6.0 || ^7", "thecodingmachine/phpstan-strict-rules": "^1.0" diff --git a/examples/no-framework/index.php b/examples/no-framework/index.php index b8f43ee18b..3db0b0fe20 100644 --- a/examples/no-framework/index.php +++ b/examples/no-framework/index.php @@ -24,8 +24,7 @@ ]); $factory = new SchemaFactory($cache, $container); -$factory->addControllerNamespace('App\\Controllers') - ->addTypeNamespace('App'); +$factory->addNamespace('App'); $schema = $factory->createSchema(); diff --git a/src/AggregateControllerQueryProvider.php b/src/AggregateControllerQueryProvider.php index 432e8623a7..41a3d07027 100644 --- a/src/AggregateControllerQueryProvider.php +++ b/src/AggregateControllerQueryProvider.php @@ -8,16 +8,12 @@ use Psr\Container\ContainerInterface; use TheCodingMachine\GraphQLite\Mappers\DuplicateMappingException; -use function array_filter; -use function array_intersect_key; -use function array_keys; use function array_map; use function array_merge; use function array_sum; use function array_values; use function assert; use function count; -use function reset; use function sort; /** @@ -94,18 +90,29 @@ private function flattenList(array $list): array } // We have an issue, let's detect the duplicate - $duplicates = array_intersect_key(...array_values($list)); - // Let's display an error from the first one. - $firstDuplicate = reset($duplicates); - assert($firstDuplicate instanceof FieldDefinition); + $queriesByName = []; + $duplicateClasses = null; + $duplicateQueryName = null; - $duplicateName = $firstDuplicate->name; + foreach ($list as $class => $queries) { + foreach ($queries as $query => $field) { + $duplicatedClass = $queriesByName[$query] ?? null; - $classes = array_keys(array_filter($list, static function (array $fields) use ($duplicateName) { - return isset($fields[$duplicateName]); - })); - sort($classes); + if (! $duplicatedClass) { + $queriesByName[$query] = $class; - throw DuplicateMappingException::createForQueryInTwoControllers($classes[0], $classes[1], $duplicateName); + continue; + } + + $duplicateClasses = [$duplicatedClass, $class]; + $duplicateQueryName = $query; + } + } + + assert($duplicateClasses !== null && $duplicateQueryName !== null); + + sort($duplicateClasses); + + throw DuplicateMappingException::createForQueryInTwoControllers($duplicateClasses[0], $duplicateClasses[1], $duplicateQueryName); } } diff --git a/src/Cache/ClassBoundCache.php b/src/Cache/ClassBoundCache.php new file mode 100644 index 0000000000..6e9d20f66b --- /dev/null +++ b/src/Cache/ClassBoundCache.php @@ -0,0 +1,24 @@ +cachePrefix = str_replace(['\\', '{', '}', '(', ')', '/', '@', ':'], '_', $cachePrefix); - } - - /** - * @param string $key An optional key to differentiate between cache items attached to the same class. - * - * @throws InvalidArgumentException - */ - public function get(ReflectionClass $reflectionClass, callable $resolver, string $key = '', int|null $ttl = null): mixed - { - $cacheKey = $reflectionClass->getName() . '__' . $key; - $cacheKey = $this->cachePrefix . str_replace(['\\', '{', '}', '(', ')', '/', '@', ':'], '_', $cacheKey); - - $item = $this->classBoundCache->get($cacheKey); - if ($item !== null) { - return $item; - } - - $item = $resolver(); - - $this->classBoundCache->set($cacheKey, $item, $ttl); - - return $item; - } -} diff --git a/src/Cache/ClassBoundCacheContractFactory.php b/src/Cache/ClassBoundCacheContractFactory.php deleted file mode 100644 index 0c2dfc7750..0000000000 --- a/src/Cache/ClassBoundCacheContractFactory.php +++ /dev/null @@ -1,15 +0,0 @@ - $dependencies */ + private function __construct( + private readonly array $dependencies, + ) + { + } + + /** @param list $files */ + public static function for(array $files): self + { + $dependencies = []; + + foreach (array_unique($files) as $file) { + $dependencies[$file] = filemtime($file); + } + + return new self($dependencies); + } + + public static function forClass(ReflectionClass $class, bool $withInheritance = false): self + { + return self::for( + self::dependencies($class, $withInheritance), + ); + } + + public static function alwaysUnchanged(): self + { + return new self([]); + } + + /** @return list */ + private static function dependencies(ReflectionClass $class, bool $withInheritance = false): array + { + $filename = $class->getFileName(); + + // Internal classes are treated as always the same, e.g. you'll have to drop the cache between PHP versions. + if ($filename === false) { + return []; + } + + $files = [$filename]; + + if (! $withInheritance) { + return $files; + } + + if ($class->getParentClass() !== false) { + $files = [...$files, ...self::dependencies($class->getParentClass(), $withInheritance)]; + } + + foreach ($class->getTraits() as $trait) { + $files = [...$files, ...self::dependencies($trait, $withInheritance)]; + } + + foreach ($class->getInterfaces() as $interface) { + $files = [...$files, ...self::dependencies($interface, $withInheritance)]; + } + + return $files; + } + + public function changed(): bool + { + foreach ($this->dependencies as $filename => $modificationTime) { + if ($modificationTime !== filemtime($filename)) { + return true; + } + } + + return false; + } +} diff --git a/src/Cache/SnapshotClassBoundCache.php b/src/Cache/SnapshotClassBoundCache.php new file mode 100644 index 0000000000..62f2a329d4 --- /dev/null +++ b/src/Cache/SnapshotClassBoundCache.php @@ -0,0 +1,41 @@ +getName() . '__' . $key; + $cacheKey = str_replace(['\\', '{', '}', '(', ')', '/', '@', ':'], '_', $cacheKey); + + $item = $this->cache->get($cacheKey); + + if ($item !== null && ! $item['snapshot']->changed()) { + return $item['data']; + } + + $item = [ + 'data' => $resolver(), + 'snapshot' => ($this->filesSnapshotFactory)($reflectionClass, $withInheritance), + ]; + + $this->cache->set($cacheKey, $item); + + return $item['data']; + } +} diff --git a/src/Discovery/Cache/ClassFinderComputedCache.php b/src/Discovery/Cache/ClassFinderComputedCache.php new file mode 100644 index 0000000000..66f7fd05c2 --- /dev/null +++ b/src/Discovery/Cache/ClassFinderComputedCache.php @@ -0,0 +1,41 @@ +. Once all classes are iterated, + * $reduce will then be called with that map, and it's final result is returned. + * + * Now the point of this is now whenever file A changes, we can automatically remove entries generated for it + * and simply call $map only for classes from file A, leaving all other entries untouched and not having to + * waste resources on the rest of them. We then only need to call the cheap $reduce and have the final result :) + * + * @param callable(ReflectionClass): TEntry $map + * @param callable(array): TReturn $reduce + * + * @return TReturn + * + * @template TEntry of mixed + * @template TReturn of mixed + */ + public function compute( + ClassFinder $classFinder, + string $key, + callable $map, + callable $reduce, + ): mixed; +} diff --git a/src/Discovery/Cache/HardClassFinderComputedCache.php b/src/Discovery/Cache/HardClassFinderComputedCache.php new file mode 100644 index 0000000000..472aa5a58a --- /dev/null +++ b/src/Discovery/Cache/HardClassFinderComputedCache.php @@ -0,0 +1,69 @@ +): TEntry $map + * @param callable(array): TReturn $reduce + * + * @return TReturn + * + * @template TEntry of mixed + * @template TReturn of mixed + */ + public function compute( + ClassFinder $classFinder, + string $key, + callable $map, + callable $reduce, + ): mixed + { + $result = $this->cache->get($key); + + if ($result !== null) { + return $result; + } + + $result = $reduce($this->entries($classFinder, $map)); + + $this->cache->set($key, $result); + + return $result; + } + + /** + * @param callable(ReflectionClass): TEntry $map + * + * @return array + * + * @template TEntry of mixed + */ + private function entries( + ClassFinder $classFinder, + callable $map, + ): mixed + { + $entries = []; + + foreach ($classFinder as $classReflection) { + $entries[$classReflection->getFileName()] = $map($classReflection); + } + + /** @phpstan-ignore return.type */ + return $entries; + } +} diff --git a/src/Discovery/Cache/SnapshotClassFinderComputedCache.php b/src/Discovery/Cache/SnapshotClassFinderComputedCache.php new file mode 100644 index 0000000000..3e4b9d2306 --- /dev/null +++ b/src/Discovery/Cache/SnapshotClassFinderComputedCache.php @@ -0,0 +1,129 @@ +): TEntry $map + * @param callable(array): TReturn $reduce + * + * @return TReturn + * + * @template TEntry of mixed + * @template TReturn of mixed + */ + public function compute( + ClassFinder $classFinder, + string $key, + callable $map, + callable $reduce, + ): mixed + { + $entries = $this->entries($classFinder, $key . '.entries', $map); + + return $reduce($entries); + } + + /** + * @param callable(ReflectionClass): TEntry $map + * + * @return array + * + * @template TEntry of mixed + */ + private function entries( + ClassFinder $classFinder, + string $key, + callable $map, + ): mixed + { + $previousEntries = $this->cache->get($key) ?? []; + /** @var array $result */ + $result = []; + $entries = []; + + // The size of the cache may be huge, so let's avoid writes when unnecessary. + $changed = false; + + $classFinder = $classFinder->withPathFilter(static function (string $filename) use (&$entries, &$result, &$changed, $previousEntries) { + /** @var array{ data: TEntry, dependencies: FilesSnapshot, matching: bool } $entry */ + $entry = $previousEntries[$filename] ?? null; + + // If there's no entry in cache for this filename (new file or previously uncached), + // or if it the file has been modified since caching, we'll try to autoload + // the class and collect the cached information (again). + if (! $entry || $entry['dependencies']->changed()) { + // In case this file isn't a class, or doesn't match the provided namespace filter, + // it will not be emitted in the iterator and won't reach the `foreach()` below. + // So to avoid iterating over these files again, we'll mark them as non-matching. + // If they are matching, it'll be overwritten in the `foreach` loop below. + $entries[$filename] = [ + 'dependencies' => FilesSnapshot::for([$filename]), + 'matching' => false, + ]; + + $changed = true; + + return true; + } + + if ($entry['matching']) { + $result[$filename] = $entry['data']; + } + + $entries[$filename] = $entry; + + return false; + }); + + foreach ($classFinder as $classReflection) { + $filename = $classReflection->getFileName(); + + $result[$filename] = $map($classReflection); + $entries[$filename] = [ + 'dependencies' => FilesSnapshot::forClass($classReflection, true), + 'data' => $result[$filename], + 'matching' => true, + ]; + + $changed = true; + } + + if ($changed) { + $this->cache->set($key, $entries); + } + + /** @phpstan-ignore return.type */ + return $result; + } +} diff --git a/src/Discovery/ClassFinder.php b/src/Discovery/ClassFinder.php new file mode 100644 index 0000000000..67d2cf3b1a --- /dev/null +++ b/src/Discovery/ClassFinder.php @@ -0,0 +1,14 @@ +> */ +interface ClassFinder extends IteratorAggregate +{ + public function withPathFilter(callable $filter): self; +} diff --git a/src/Discovery/KcsClassFinder.php b/src/Discovery/KcsClassFinder.php new file mode 100644 index 0000000000..d54616b8ec --- /dev/null +++ b/src/Discovery/KcsClassFinder.php @@ -0,0 +1,32 @@ +finder = (clone $that->finder)->pathFilter($filter); + + return $that; + } + + /** @return Traversable */ + public function getIterator(): Traversable + { + return $this->finder->getIterator(); + } +} diff --git a/src/Discovery/StaticClassFinder.php b/src/Discovery/StaticClassFinder.php new file mode 100644 index 0000000000..7eedb617b0 --- /dev/null +++ b/src/Discovery/StaticClassFinder.php @@ -0,0 +1,44 @@ + $classes */ + public function __construct( + private readonly array $classes, + ) + { + } + + public function withPathFilter(callable $filter): ClassFinder + { + $that = clone $this; + $that->pathFilter = $filter; + + return $that; + } + + /** @return Traversable */ + public function getIterator(): Traversable + { + foreach ($this->classes as $class) { + $classReflection = new ReflectionClass($class); + + /** @phpstan-ignore-next-line */ + if ($this->pathFilter && ! ($this->pathFilter)($classReflection->getFileName())) { + continue; + } + + yield $class => $classReflection; + } + } +} diff --git a/src/FactoryContext.php b/src/FactoryContext.php index be64043ade..0aaf40ac42 100644 --- a/src/FactoryContext.php +++ b/src/FactoryContext.php @@ -6,7 +6,9 @@ use Psr\Container\ContainerInterface; use Psr\SimpleCache\CacheInterface; -use TheCodingMachine\GraphQLite\Cache\ClassBoundCacheContractFactoryInterface; +use TheCodingMachine\GraphQLite\Cache\ClassBoundCache; +use TheCodingMachine\GraphQLite\Discovery\Cache\ClassFinderComputedCache; +use TheCodingMachine\GraphQLite\Discovery\ClassFinder; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapperInterface; use TheCodingMachine\GraphQLite\Types\InputTypeValidatorInterface; use TheCodingMachine\GraphQLite\Types\TypeResolver; @@ -30,9 +32,9 @@ public function __construct( private readonly ContainerInterface $container, private readonly CacheInterface $cache, private readonly InputTypeValidatorInterface|null $inputTypeValidator, - private readonly int|null $globTTL, - private readonly int|null $mapTTL = null, - private readonly ClassBoundCacheContractFactoryInterface|null $classBoundCacheContractFactory = null, + private readonly ClassFinder $classFinder, + private readonly ClassFinderComputedCache $classFinderComputedCache, + private readonly ClassBoundCache $classBoundCache, ) { } @@ -86,23 +88,23 @@ public function getCache(): CacheInterface return $this->cache; } - public function getClassBoundCacheContractFactory(): ClassBoundCacheContractFactoryInterface|null + public function getInputTypeValidator(): InputTypeValidatorInterface|null { - return $this->classBoundCacheContractFactory; + return $this->inputTypeValidator; } - public function getInputTypeValidator(): InputTypeValidatorInterface|null + public function getClassFinder(): ClassFinder { - return $this->inputTypeValidator; + return $this->classFinder; } - public function getGlobTTL(): int|null + public function getClassFinderComputedCache(): ClassFinderComputedCache { - return $this->globTTL; + return $this->classFinderComputedCache; } - public function getMapTTL(): int|null + public function getClassBoundCache(): ClassBoundCache|null { - return $this->mapTTL; + return $this->classBoundCache; } } diff --git a/src/FieldsBuilder.php b/src/FieldsBuilder.php index de99011166..d8ab99cc6b 100644 --- a/src/FieldsBuilder.php +++ b/src/FieldsBuilder.php @@ -46,7 +46,7 @@ use TheCodingMachine\GraphQLite\Parameters\InputTypeParameterInterface; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; use TheCodingMachine\GraphQLite\Parameters\PrefetchDataParameter; -use TheCodingMachine\GraphQLite\Reflection\CachedDocBlockFactory; +use TheCodingMachine\GraphQLite\Reflection\DocBlock\DocBlockFactory; use TheCodingMachine\GraphQLite\Types\ArgumentResolver; use TheCodingMachine\GraphQLite\Types\MutableObjectType; use TheCodingMachine\GraphQLite\Types\TypeResolver; @@ -84,7 +84,7 @@ public function __construct( private readonly RecursiveTypeMapperInterface $recursiveTypeMapper, private readonly ArgumentResolver $argumentResolver, private readonly TypeResolver $typeResolver, - private readonly CachedDocBlockFactory $cachedDocBlockFactory, + private readonly DocBlockFactory $docBlockFactory, private readonly NamingStrategyInterface $namingStrategy, private readonly RootTypeMapperInterface $rootTypeMapper, private readonly ParameterMiddlewareInterface $parameterMapper, @@ -96,7 +96,7 @@ public function __construct( $this->argumentResolver, $this->rootTypeMapper, $this->typeResolver, - $this->cachedDocBlockFactory, + $this->docBlockFactory, ); } @@ -309,7 +309,7 @@ public function getSelfFields(string $className, string|null $typeName = null): */ public function getParameters(ReflectionMethod $refMethod, int $skip = 0): array { - $docBlockObj = $this->cachedDocBlockFactory->getDocBlock($refMethod); + $docBlockObj = $this->docBlockFactory->create($refMethod); //$docBlockComment = $docBlockObj->getSummary()."\n".$docBlockObj->getDescription()->render(); $parameters = array_slice($refMethod->getParameters(), $skip); @@ -455,7 +455,7 @@ private function getFieldsByMethodAnnotations( $description = $queryAnnotation->getDescription(); } - $docBlockObj = $this->cachedDocBlockFactory->getDocBlock($refMethod); + $docBlockObj = $this->docBlockFactory->create($refMethod); $name = $queryAnnotation->getName() ?: $this->namingStrategy->getFieldNameFromMethodName($methodName); @@ -565,7 +565,7 @@ private function getFieldsByPropertyAnnotations( $description = $queryAnnotation->getDescription(); } - $docBlock = $this->cachedDocBlockFactory->getDocBlock($refProperty); + $docBlock = $this->docBlockFactory->create($refProperty); $name = $queryAnnotation->getName() ?: $refProperty->getName(); @@ -684,7 +684,7 @@ private function getQueryFieldsFromSourceFields( throw FieldNotFoundException::wrapWithCallerInfo($e, $refClass->getName()); } - $docBlockObj = $this->cachedDocBlockFactory->getDocBlock($refMethod); + $docBlockObj = $this->docBlockFactory->create($refMethod); $docBlockComment = rtrim($docBlockObj->getSummary() . "\n" . $docBlockObj->getDescription()->render()); $deprecated = $docBlockObj->getTagsByName('deprecated'); @@ -820,7 +820,7 @@ private function resolvePhpType( { $typeResolver = new \phpDocumentor\Reflection\TypeResolver(); - $context = $this->cachedDocBlockFactory->getContextFromClass($refClass); + $context = $this->docBlockFactory->createContext($refClass); $phpdocType = $typeResolver->resolve($phpTypeStr, $context); assert($phpdocType !== null); @@ -969,7 +969,7 @@ private function getPrefetchParameter( $prefetchParameters = $prefetchRefMethod->getParameters(); array_shift($prefetchParameters); - $prefetchDocBlockObj = $this->cachedDocBlockFactory->getDocBlock($prefetchRefMethod); + $prefetchDocBlockObj = $this->docBlockFactory->create($prefetchRefMethod); $prefetchArgs = $this->mapParameters($prefetchParameters, $prefetchDocBlockObj); return new PrefetchDataParameter( @@ -1025,7 +1025,7 @@ private function getInputFieldsByMethodAnnotations( continue; } - $docBlockObj = $this->cachedDocBlockFactory->getDocBlock($refMethod); + $docBlockObj = $this->docBlockFactory->create($refMethod); $methodName = $refMethod->getName(); if (! str_starts_with($methodName, 'set')) { continue; @@ -1122,7 +1122,7 @@ private function getInputFieldsByPropertyAnnotations( $fields = []; $annotations = $this->annotationReader->getPropertyAnnotations($refProperty, $annotationName); - $docBlock = $this->cachedDocBlockFactory->getDocBlock($refProperty); + $docBlock = $this->docBlockFactory->create($refProperty); foreach ($annotations as $annotation) { $description = null; diff --git a/src/GlobControllerQueryProvider.php b/src/GlobControllerQueryProvider.php index f7ed17170e..69f66e0d70 100644 --- a/src/GlobControllerQueryProvider.php +++ b/src/GlobControllerQueryProvider.php @@ -5,22 +5,17 @@ namespace TheCodingMachine\GraphQLite; use GraphQL\Type\Definition\FieldDefinition; -use InvalidArgumentException; -use Kcs\ClassFinder\Finder\FinderInterface; use Psr\Container\ContainerInterface; -use Psr\SimpleCache\CacheInterface; use ReflectionClass; use ReflectionMethod; -use Symfony\Component\Cache\Adapter\Psr16Adapter; -use Symfony\Contracts\Cache\CacheInterface as CacheContractInterface; use TheCodingMachine\GraphQLite\Annotations\Mutation; use TheCodingMachine\GraphQLite\Annotations\Query; use TheCodingMachine\GraphQLite\Annotations\Subscription; +use TheCodingMachine\GraphQLite\Discovery\Cache\ClassFinderComputedCache; +use TheCodingMachine\GraphQLite\Discovery\ClassFinder; -use function class_exists; -use function interface_exists; -use function is_array; -use function str_replace; +use function array_filter; +use function array_values; /** * Scans all the classes in a given namespace of the main project (not the vendor directory). @@ -30,36 +25,25 @@ */ final class GlobControllerQueryProvider implements QueryProviderInterface { - /** @var array|null */ - private array|null $instancesList = null; + /** @var array */ + private array $classList; private AggregateControllerQueryProvider|null $aggregateControllerQueryProvider = null; - private CacheContractInterface $cacheContract; - /** - * @param string $namespace The namespace that contains the GraphQL types (they must have a `@Type` annotation) - * @param ContainerInterface $container The container we will fetch controllers from. - */ + /** @param ContainerInterface $container The container we will fetch controllers from. */ public function __construct( - private readonly string $namespace, private readonly FieldsBuilder $fieldsBuilder, private readonly ContainerInterface $container, private readonly AnnotationReader $annotationReader, - private readonly CacheInterface $cache, - private readonly FinderInterface $finder, - int|null $cacheTtl = null, + private readonly ClassFinder $classFinder, + private readonly ClassFinderComputedCache $classFinderComputedCache, ) { - $this->cacheContract = new Psr16Adapter( - $this->cache, - str_replace(['\\', '{', '}', '(', ')', '/', '@', ':'], '_', $namespace), - $cacheTtl ?? 0, - ); } private function getAggregateControllerQueryProvider(): AggregateControllerQueryProvider { $this->aggregateControllerQueryProvider ??= new AggregateControllerQueryProvider( - $this->getInstancesList(), + $this->getClassList(), $this->fieldsBuilder, $this->container, ); @@ -70,46 +54,30 @@ private function getAggregateControllerQueryProvider(): AggregateControllerQuery /** * Returns an array of fully qualified class names. * - * @return array + * @return array */ - private function getInstancesList(): array + private function getClassList(): array { - if ($this->instancesList === null) { - $this->instancesList = $this->cacheContract->get( - 'globQueryProvider', - fn () => $this->buildInstancesList(), - ); - - if (! is_array($this->instancesList)) { - throw new InvalidArgumentException('The instance list returned is not an array. There might be an issue with your PSR-16 cache implementation.'); - } - } - - return $this->instancesList; - } - - /** @return array */ - private function buildInstancesList(): array - { - $instances = []; - foreach ((clone $this->finder)->inNamespace($this->namespace) as $className => $refClass) { - if (! class_exists($className) && ! interface_exists($className)) { - continue; - } - if (! $refClass instanceof ReflectionClass || ! $refClass->isInstantiable()) { - continue; - } - if (! $this->hasOperations($refClass)) { - continue; - } - if (! $this->container->has($className)) { - continue; - } - - $instances[] = $className; - } + /** @phpstan-ignore assign.propertyType */ + $this->classList ??= $this->classFinderComputedCache->compute( + $this->classFinder, + 'globQueryProvider', + function (ReflectionClass $classReflection): string|null { + if ( + ! $classReflection->isInstantiable() || + ! $this->hasOperations($classReflection) || + ! $this->container->has($classReflection->getName()) + ) { + return null; + } + + return $classReflection->getName(); + }, + static fn (array $entries) => array_values(array_filter($entries)), + ); - return $instances; + /** @phpstan-ignore return.type */ + return $this->classList; } /** @param ReflectionClass $reflectionClass */ diff --git a/src/Mappers/AbstractTypeMapper.php b/src/Mappers/ClassFinderTypeMapper.php similarity index 68% rename from src/Mappers/AbstractTypeMapper.php rename to src/Mappers/ClassFinderTypeMapper.php index 7e09f01ed5..c9911d4b28 100644 --- a/src/Mappers/AbstractTypeMapper.php +++ b/src/Mappers/ClassFinderTypeMapper.php @@ -9,16 +9,12 @@ use GraphQL\Type\Definition\OutputType; use GraphQL\Type\Definition\Type; use Psr\Container\ContainerInterface; -use Psr\SimpleCache\CacheInterface; use ReflectionClass; use ReflectionException; use ReflectionMethod; -use Symfony\Component\Cache\Adapter\Psr16Adapter; -use Symfony\Contracts\Cache\CacheInterface as CacheContractInterface; use TheCodingMachine\GraphQLite\AnnotationReader; -use TheCodingMachine\GraphQLite\Cache\ClassBoundCacheContractFactory; -use TheCodingMachine\GraphQLite\Cache\ClassBoundCacheContractFactoryInterface; -use TheCodingMachine\GraphQLite\Cache\ClassBoundCacheContractInterface; +use TheCodingMachine\GraphQLite\Discovery\Cache\ClassFinderComputedCache; +use TheCodingMachine\GraphQLite\Discovery\ClassFinder; use TheCodingMachine\GraphQLite\InputTypeGenerator; use TheCodingMachine\GraphQLite\InputTypeUtils; use TheCodingMachine\GraphQLite\NamingStrategyInterface; @@ -28,32 +24,19 @@ use TheCodingMachine\GraphQLite\Types\MutableObjectType; use TheCodingMachine\GraphQLite\Types\ResolvableMutableInputInterface; +use function array_reduce; use function assert; /** * Analyzes classes and uses the @Type annotation to find the types automatically. - * - * Assumes that the container contains a class whose identifier is the same as the class name. */ -abstract class AbstractTypeMapper implements TypeMapperInterface +class ClassFinderTypeMapper implements TypeMapperInterface { - /** - * Cache storing the GlobAnnotationsCache objects linked to a given ReflectionClass. - */ - private ClassBoundCacheContractInterface $mapClassToAnnotationsCache; - /** - * Cache storing the GlobAnnotationsCache objects linked to a given ReflectionClass. - */ - private ClassBoundCacheContractInterface $mapClassToExtendAnnotationsCache; - - private CacheContractInterface $cacheContract; private GlobTypeMapperCache|null $globTypeMapperCache = null; private GlobExtendTypeMapperCache|null $globExtendTypeMapperCache = null; - /** @var array> */ - private array $registeredInputs; public function __construct( - string $cachePrefix, + private readonly ClassFinder $classFinder, private readonly TypeGenerator $typeGenerator, private readonly InputTypeGenerator $inputTypeGenerator, private readonly InputTypeUtils $inputTypeUtils, @@ -61,18 +44,9 @@ public function __construct( private readonly AnnotationReader $annotationReader, private readonly NamingStrategyInterface $namingStrategy, private readonly RecursiveTypeMapperInterface $recursiveTypeMapper, - private readonly CacheInterface $cache, - protected int|null $globTTL = 2, - private readonly int|null $mapTTL = null, - ClassBoundCacheContractFactoryInterface|null $classBoundCacheContractFactory = null, + private readonly ClassFinderComputedCache $classFinderComputedCache, ) { - $this->cacheContract = new Psr16Adapter($this->cache, $cachePrefix, $this->globTTL ?? 0); - - $classBoundCacheContractFactory = $classBoundCacheContractFactory ?? new ClassBoundCacheContractFactory(); - - $this->mapClassToAnnotationsCache = $classBoundCacheContractFactory->make($cache, 'classToAnnotations_' . $cachePrefix); - $this->mapClassToExtendAnnotationsCache = $classBoundCacheContractFactory->make($cache, 'classToExtendAnnotations_' . $cachePrefix); } /** @@ -80,48 +54,17 @@ public function __construct( */ private function getMaps(): GlobTypeMapperCache { - if ($this->globTypeMapperCache === null) { - $this->globTypeMapperCache = $this->cacheContract->get('fullMapComputed', function () { - return $this->buildMap(); - }); - } - - return $this->globTypeMapperCache; - } - - private function getMapClassToExtendTypeArray(): GlobExtendTypeMapperCache - { - if ($this->globExtendTypeMapperCache === null) { - $this->globExtendTypeMapperCache = $this->cacheContract->get('fullExtendMapComputed', function () { - return $this->buildMapClassToExtendTypeArray(); - }); - } - - return $this->globExtendTypeMapperCache; - } - - /** - * Returns the array of globbed classes. - * Only instantiable classes are returned. - * - * @return array> Key: fully qualified class name - */ - abstract protected function getClassList(): array; - - private function buildMap(): GlobTypeMapperCache - { - $globTypeMapperCache = new GlobTypeMapperCache(); - - /** @var array,ReflectionClass> $classes */ - $classes = $this->getClassList(); + $this->globTypeMapperCache ??= $this->classFinderComputedCache->compute( + $this->classFinder, + 'classToAnnotations', + function (ReflectionClass $refClass): GlobAnnotationsCache|null { + if ($refClass->isEnum()) { + return null; + } - foreach ($classes as $className => $refClass) { - // Enum's are not types - if ($refClass->isEnum()) { - continue; - } - $annotationsCache = $this->mapClassToAnnotationsCache->get($refClass, function () use ($refClass, $className) { - $annotationsCache = new GlobAnnotationsCache(); + $annotationsCache = new GlobAnnotationsCache( + $className = $refClass->getName(), + ); $containsAnnotations = false; @@ -135,11 +78,6 @@ private function buildMap(): GlobTypeMapperCache $inputs = $this->annotationReader->getInputAnnotations($refClass); foreach ($inputs as $input) { $inputName = $this->namingStrategy->getInputTypeName($className, $input); - if (isset($this->registeredInputs[$inputName])) { - throw DuplicateMappingException::createForTwoInputs($inputName, $this->registeredInputs[$inputName], $refClass->getName()); - } - - $this->registeredInputs[$inputName] = $refClass->getName(); $annotationsCache = $annotationsCache->registerInput($inputName, $className, $input); $containsAnnotations = true; } @@ -169,71 +107,73 @@ private function buildMap(): GlobTypeMapperCache $containsAnnotations = true; } - if (! $containsAnnotations) { - return 'nothing'; + return $containsAnnotations ? $annotationsCache : null; + }, + static fn (array $entries) => array_reduce($entries, static function (GlobTypeMapperCache $globTypeMapperCache, GlobAnnotationsCache|null $annotationsCache) { + if ($annotationsCache === null) { + return $globTypeMapperCache; } - return $annotationsCache; - }, '', $this->mapTTL); + $globTypeMapperCache->registerAnnotations($annotationsCache->sourceClass, $annotationsCache); - if ($annotationsCache === 'nothing') { - continue; - } + return $globTypeMapperCache; + }, new GlobTypeMapperCache()), + ); - $globTypeMapperCache->registerAnnotations($refClass, $annotationsCache); - } - - return $globTypeMapperCache; + return $this->globTypeMapperCache; } - private function buildMapClassToExtendTypeArray(): GlobExtendTypeMapperCache + private function getMapClassToExtendTypeArray(): GlobExtendTypeMapperCache { - $globExtendTypeMapperCache = new GlobExtendTypeMapperCache(); - - $classes = $this->getClassList(); - foreach ($classes as $refClass) { - // Enum's are not types - if ($refClass->isEnum()) { - continue; - } - $annotationsCache = $this->mapClassToExtendAnnotationsCache->get($refClass, function () use ($refClass) { + $this->globExtendTypeMapperCache ??= $this->classFinderComputedCache->compute( + $this->classFinder, + 'classToExtendAnnotations', + function (ReflectionClass $refClass): GlobExtendAnnotationsCache|null { + // Enum's are not types + if ($refClass->isEnum()) { + return null; + } + $extendType = $this->annotationReader->getExtendTypeAnnotation($refClass); - if ($extendType !== null) { - $extendClassName = $extendType->getClass(); - if ($extendClassName !== null) { - try { - $targetType = $this->recursiveTypeMapper->mapClassToType($extendClassName, null); - } catch (CannotMapTypeException $e) { - $e->addExtendTypeInfo($refClass, $extendType); - throw $e; - } - $typeName = $targetType->name; - } else { - $typeName = $extendType->getName(); - assert($typeName !== null); - $targetType = $this->recursiveTypeMapper->mapNameToType($typeName); - if (! $targetType instanceof MutableObjectType) { - throw CannotMapTypeException::extendTypeWithBadTargetedClass($refClass->getName(), $extendType); - } - $extendClassName = $targetType->getMappedClassName(); - } + if ($extendType === null) { + return null; + } - // FIXME: $extendClassName === NULL!!!!!! - return new GlobExtendAnnotationsCache($extendClassName, $typeName); + $extendClassName = $extendType->getClass(); + if ($extendClassName !== null) { + try { + $targetType = $this->recursiveTypeMapper->mapClassToType($extendClassName, null); + } catch (CannotMapTypeException $e) { + $e->addExtendTypeInfo($refClass, $extendType); + throw $e; + } + $typeName = $targetType->name; + } else { + $typeName = $extendType->getName(); + assert($typeName !== null); + $targetType = $this->recursiveTypeMapper->mapNameToType($typeName); + if (! $targetType instanceof MutableObjectType) { + throw CannotMapTypeException::extendTypeWithBadTargetedClass($refClass->getName(), $extendType); + } + $extendClassName = $targetType->getMappedClassName(); } - return 'nothing'; - }, '', $this->mapTTL); + // FIXME: $extendClassName === NULL!!!!!! + return new GlobExtendAnnotationsCache($refClass->getName(), $extendClassName, $typeName); + }, + static fn (array $entries) => array_reduce($entries, static function (GlobExtendTypeMapperCache $globExtendTypeMapperCache, GlobExtendAnnotationsCache|null $annotationsCache) { + if ($annotationsCache === null) { + return $globExtendTypeMapperCache; + } - if ($annotationsCache === 'nothing') { - continue; - } + $globExtendTypeMapperCache->registerAnnotations($annotationsCache->sourceClass, $annotationsCache); - $globExtendTypeMapperCache->registerAnnotations($refClass, $annotationsCache); - } + return $globExtendTypeMapperCache; + }, new GlobExtendTypeMapperCache()), + ); - return $globExtendTypeMapperCache; + return $this->globExtendTypeMapperCache; } /** diff --git a/src/Mappers/GlobAnnotationsCache.php b/src/Mappers/GlobAnnotationsCache.php index dd41c665e4..0ecef554a3 100644 --- a/src/Mappers/GlobAnnotationsCache.php +++ b/src/Mappers/GlobAnnotationsCache.php @@ -17,6 +17,7 @@ final class GlobAnnotationsCache use Cloneable; /** + * @param class-string $sourceClass * @param class-string|null $typeClassName * @param array|null, 2:bool, 3:class-string}> $factories * An array mapping a factory method name to an input name / class name / default flag / @@ -27,6 +28,7 @@ final class GlobAnnotationsCache * An array mapping an input type name to an input name / declaring class */ public function __construct( + public readonly string $sourceClass, private readonly string|null $typeClassName = null, private readonly string|null $typeName = null, private readonly bool $default = false, @@ -108,6 +110,10 @@ public function getDecorators(): array */ public function registerInput(string $name, string $className, Input $input): self { + if (isset($this->inputs[$name])) { + throw DuplicateMappingException::createForTwoInputs($name, $this->inputs[$name][0], $className); + } + return $this->with( inputs: [ ...$this->inputs, diff --git a/src/Mappers/GlobExtendAnnotationsCache.php b/src/Mappers/GlobExtendAnnotationsCache.php index b99d1acd28..ca62e9536a 100644 --- a/src/Mappers/GlobExtendAnnotationsCache.php +++ b/src/Mappers/GlobExtendAnnotationsCache.php @@ -11,7 +11,9 @@ */ final class GlobExtendAnnotationsCache { + /** @param class-string $sourceClass */ public function __construct( + public readonly string $sourceClass, private string|null $extendTypeClassName, private string $extendTypeName, ) diff --git a/src/Mappers/GlobExtendTypeMapperCache.php b/src/Mappers/GlobExtendTypeMapperCache.php index 590fe49220..046f5444d5 100644 --- a/src/Mappers/GlobExtendTypeMapperCache.php +++ b/src/Mappers/GlobExtendTypeMapperCache.php @@ -19,11 +19,11 @@ class GlobExtendTypeMapperCache /** * Merges annotations of a given class in the global cache. * - * @param ReflectionClass $refClass + * @param ReflectionClass|class-string $sourceClass */ - public function registerAnnotations(ReflectionClass $refClass, GlobExtendAnnotationsCache $globExtendAnnotationsCache): void + public function registerAnnotations(ReflectionClass|string $sourceClass, GlobExtendAnnotationsCache $globExtendAnnotationsCache): void { - $className = $refClass->getName(); + $className = $sourceClass instanceof ReflectionClass ? $sourceClass->getName() : $sourceClass; $typeClassName = $globExtendAnnotationsCache->getExtendTypeClassName(); if ($typeClassName !== null) { diff --git a/src/Mappers/GlobTypeMapper.php b/src/Mappers/GlobTypeMapper.php deleted file mode 100644 index f094eb7359..0000000000 --- a/src/Mappers/GlobTypeMapper.php +++ /dev/null @@ -1,82 +0,0 @@ -getNamespace(), - ); - - parent::__construct( - $cachePrefix, - $typeGenerator, - $inputTypeGenerator, - $inputTypeUtils, - $container, - $annotationReader, - $namingStrategy, - $recursiveTypeMapper, - $cache, - $globTTL, - $mapTTL, - $classBoundCacheContractFactory, - ); - } - - /** - * Returns the array of globbed classes. - * Only instantiable classes are returned. - * - * @return array> Key: fully qualified class name - */ - protected function getClassList(): array - { - return $this->namespace->getClassList(); - } -} diff --git a/src/Mappers/GlobTypeMapperCache.php b/src/Mappers/GlobTypeMapperCache.php index 24be49646c..032f0f51c9 100644 --- a/src/Mappers/GlobTypeMapperCache.php +++ b/src/Mappers/GlobTypeMapperCache.php @@ -31,11 +31,11 @@ class GlobTypeMapperCache /** * Merges annotations of a given class in the global cache. * - * @param ReflectionClass $refClass + * @param ReflectionClass|class-string $sourceClass */ - public function registerAnnotations(ReflectionClass $refClass, GlobAnnotationsCache $globAnnotationsCache): void + public function registerAnnotations(ReflectionClass|string $sourceClass, GlobAnnotationsCache $globAnnotationsCache): void { - $className = $refClass->getName(); + $className = $sourceClass instanceof ReflectionClass ? $sourceClass->getName() : $sourceClass; $typeClassName = $globAnnotationsCache->getTypeClassName(); if ($typeClassName !== null) { @@ -55,7 +55,7 @@ public function registerAnnotations(ReflectionClass $refClass, GlobAnnotationsCa foreach ($globAnnotationsCache->getFactories() as $methodName => [$inputName, $inputClassName, $isDefault, $declaringClass]) { if ($isDefault) { if ($inputClassName !== null && isset($this->mapClassToFactory[$inputClassName])) { - throw DuplicateMappingException::createForFactory($inputClassName, $this->mapClassToFactory[$inputClassName][0], $this->mapClassToFactory[$inputClassName][1], $refClass->getName(), $methodName); + throw DuplicateMappingException::createForFactory($inputClassName, $this->mapClassToFactory[$inputClassName][0], $this->mapClassToFactory[$inputClassName][1], $className, $methodName); } } else { // If this is not the default factory, let's not map the class name to the factory. @@ -72,12 +72,16 @@ public function registerAnnotations(ReflectionClass $refClass, GlobAnnotationsCa foreach ($globAnnotationsCache->getInputs() as $inputName => [$inputClassName, $isDefault, $description, $isUpdate]) { if ($isDefault) { if (isset($this->mapClassToInput[$inputClassName])) { - throw DuplicateMappingException::createForDefaultInput($refClass->getName()); + throw DuplicateMappingException::createForDefaultInput($className); } $this->mapClassToInput[$inputClassName] = [$className, $inputName, $description, $isUpdate]; } + if (isset($this->mapNameToInput[$inputName])) { + throw DuplicateMappingException::createForTwoInputs($inputName, $this->mapNameToInput[$inputName][0], $inputClassName); + } + $this->mapNameToInput[$inputName] = [$inputClassName, $description, $isUpdate]; } diff --git a/src/Mappers/Parameters/TypeHandler.php b/src/Mappers/Parameters/TypeHandler.php index 8a8eadfe1d..b4b8d098ee 100644 --- a/src/Mappers/Parameters/TypeHandler.php +++ b/src/Mappers/Parameters/TypeHandler.php @@ -42,7 +42,7 @@ use TheCodingMachine\GraphQLite\Parameters\InputTypeParameter; use TheCodingMachine\GraphQLite\Parameters\InputTypeProperty; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; -use TheCodingMachine\GraphQLite\Reflection\CachedDocBlockFactory; +use TheCodingMachine\GraphQLite\Reflection\DocBlock\DocBlockFactory; use TheCodingMachine\GraphQLite\Types\ArgumentResolver; use TheCodingMachine\GraphQLite\Types\TypeResolver; @@ -68,7 +68,7 @@ public function __construct( private readonly ArgumentResolver $argumentResolver, private readonly RootTypeMapperInterface $rootTypeMapper, private readonly TypeResolver $typeResolver, - private readonly CachedDocBlockFactory $cachedDocBlockFactory, + private readonly DocBlockFactory $docBlockFactory, ) { $this->phpDocumentorTypeResolver = new PhpDocumentorTypeResolver(); @@ -135,7 +135,7 @@ private function getDocBlockPropertyType(DocBlock $docBlock, ReflectionProperty return null; } - $docBlock = $this->cachedDocBlockFactory->getDocBlock($refConstructor); + $docBlock = $this->docBlockFactory->create($refConstructor); $paramTags = $docBlock->getTagsByName('param'); foreach ($paramTags as $paramTag) { if (! $paramTag instanceof Param) { diff --git a/src/Mappers/Root/EnumTypeMapper.php b/src/Mappers/Root/EnumTypeMapper.php index 89a7e041e9..64d4696b9c 100644 --- a/src/Mappers/Root/EnumTypeMapper.php +++ b/src/Mappers/Root/EnumTypeMapper.php @@ -10,19 +10,22 @@ use GraphQL\Type\Definition\Type as GraphQLType; use MyCLabs\Enum\Enum; use phpDocumentor\Reflection\DocBlock; -use phpDocumentor\Reflection\DocBlockFactory; use phpDocumentor\Reflection\Type; use phpDocumentor\Reflection\Types\Object_; use ReflectionClass; use ReflectionEnum; use ReflectionMethod; use ReflectionProperty; -use Symfony\Contracts\Cache\CacheInterface; use TheCodingMachine\GraphQLite\AnnotationReader; +use TheCodingMachine\GraphQLite\Discovery\Cache\ClassFinderComputedCache; +use TheCodingMachine\GraphQLite\Discovery\ClassFinder; +use TheCodingMachine\GraphQLite\Reflection\DocBlock\DocBlockFactory; use TheCodingMachine\GraphQLite\Types\EnumType; -use TheCodingMachine\GraphQLite\Utils\Namespaces\NS; use UnitEnum; +use function array_filter; +use function array_merge; +use function array_values; use function assert; use function enum_exists; use function ltrim; @@ -33,18 +36,18 @@ class EnumTypeMapper implements RootTypeMapperInterface { /** @var array, EnumType> */ - private array $cache = []; + private array $cacheByClass = []; /** @var array */ private array $cacheByName = []; /** @var array> */ - private array|null $nameToClassMapping = null; + private array $nameToClassMapping; - /** @param NS[] $namespaces List of namespaces containing enums. Used when searching an enum by name. */ public function __construct( private readonly RootTypeMapperInterface $next, private readonly AnnotationReader $annotationReader, - private readonly CacheInterface $cacheService, - private readonly array $namespaces, + private readonly DocBlockFactory $docBlockFactory, + private readonly ClassFinder $classFinder, + private readonly ClassFinderComputedCache $classFinderComputedCache, ) { } @@ -102,8 +105,8 @@ private function mapByClassName(string $enumClass): EnumType|null } /** @var class-string $enumClass */ $enumClass = ltrim($enumClass, '\\'); - if (isset($this->cache[$enumClass])) { - return $this->cache[$enumClass]; + if (isset($this->cacheByClass[$enumClass])) { + return $this->cacheByClass[$enumClass]; } // phpcs:disable SlevomatCodingStandard.Commenting.InlineDocCommentDeclaration.MissingVariable @@ -121,14 +124,9 @@ private function mapByClassName(string $enumClass): EnumType|null $reflectionEnum->isBacked() && (string) $reflectionEnum->getBackingType() === 'string'; - $docBlockFactory = DocBlockFactory::createInstance(); - - $enumDescription = null; - $docComment = $reflectionEnum->getDocComment(); - if ($docComment) { - $docBlock = $docBlockFactory->create($docComment); - $enumDescription = $docBlock->getSummary(); - } + $enumDescription = $this->docBlockFactory + ->create($reflectionEnum) + ->getSummary() ?: null; /** @var array $enumCaseDescriptions */ $enumCaseDescriptions = []; @@ -136,15 +134,9 @@ private function mapByClassName(string $enumClass): EnumType|null $enumCaseDeprecationReasons = []; foreach ($reflectionEnum->getCases() as $reflectionEnumCase) { - $docComment = $reflectionEnumCase->getDocComment(); - if (! $docComment) { - continue; - } - - $docBlock = $docBlockFactory->create($docComment); - $enumCaseDescription = $docBlock->getSummary(); + $docBlock = $this->docBlockFactory->create($reflectionEnumCase); - $enumCaseDescriptions[$reflectionEnumCase->getName()] = $enumCaseDescription; + $enumCaseDescriptions[$reflectionEnumCase->getName()] = $docBlock->getSummary() ?: null; $deprecation = $docBlock->getTagsByName('deprecated')[0] ?? null; // phpcs:ignore @@ -155,7 +147,7 @@ private function mapByClassName(string $enumClass): EnumType|null $type = new EnumType($enumClass, $typeName, $enumDescription, $enumCaseDescriptions, $enumCaseDeprecationReasons, $useValues); - return $this->cacheByName[$type->name] = $this->cache[$enumClass] = $type; + return $this->cacheByName[$type->name] = $this->cacheByClass[$enumClass] = $type; } private function getTypeName(ReflectionClass $reflectionClass): string @@ -199,21 +191,19 @@ public function mapNameToType(string $typeName): NamedType&GraphQLType */ private function getNameToClassMapping(): array { - if ($this->nameToClassMapping === null) { - $this->nameToClassMapping = $this->cacheService->get('enum_name_to_class', function () { - $nameToClassMapping = []; - foreach ($this->namespaces as $ns) { - foreach ($ns->getClassList() as $className => $classRef) { - if (! enum_exists($className)) { - continue; - } - - $nameToClassMapping[$this->getTypeName($classRef)] = $className; - } + $this->nameToClassMapping ??= $this->classFinderComputedCache->compute( + $this->classFinder, + 'enum_name_to_class', + function (ReflectionClass $classReflection): array|null { + if (! $classReflection->isEnum()) { + return null; } - return $nameToClassMapping; - }); - } + + return [$this->getTypeName($classReflection) => $classReflection->getName()]; + }, + static fn (array $entries) => array_merge(...array_values(array_filter($entries))), + ); + return $this->nameToClassMapping; } } diff --git a/src/Mappers/Root/MyCLabsEnumTypeMapper.php b/src/Mappers/Root/MyCLabsEnumTypeMapper.php index 7a2d7eae2c..2a3bed7906 100644 --- a/src/Mappers/Root/MyCLabsEnumTypeMapper.php +++ b/src/Mappers/Root/MyCLabsEnumTypeMapper.php @@ -15,11 +15,14 @@ use ReflectionClass; use ReflectionMethod; use ReflectionProperty; -use Symfony\Contracts\Cache\CacheInterface; use TheCodingMachine\GraphQLite\AnnotationReader; +use TheCodingMachine\GraphQLite\Discovery\Cache\ClassFinderComputedCache; +use TheCodingMachine\GraphQLite\Discovery\ClassFinder; use TheCodingMachine\GraphQLite\Types\MyCLabsEnumType; -use TheCodingMachine\GraphQLite\Utils\Namespaces\NS; +use function array_filter; +use function array_merge; +use function array_values; use function assert; use function is_a; use function ltrim; @@ -30,19 +33,18 @@ class MyCLabsEnumTypeMapper implements RootTypeMapperInterface { /** @var array, EnumType> */ - private array $cache = []; + private array $cacheByClass = []; /** @var array */ private array $cacheByName = []; /** @var array> */ - private array|null $nameToClassMapping = null; + private array $nameToClassMapping; - /** @param NS[] $namespaces List of namespaces containing enums. Used when searching an enum by name. */ public function __construct( private readonly RootTypeMapperInterface $next, private readonly AnnotationReader $annotationReader, - private readonly CacheInterface $cacheService, - private readonly array $namespaces, + private readonly ClassFinder $classFinder, + private readonly ClassFinderComputedCache $classFinderComputedCache, ) { } @@ -98,13 +100,13 @@ private function mapByClassName(string $enumClass): EnumType|null } /** @var class-string $enumClass */ $enumClass = ltrim($enumClass, '\\'); - if (isset($this->cache[$enumClass])) { - return $this->cache[$enumClass]; + if (isset($this->cacheByClass[$enumClass])) { + return $this->cacheByClass[$enumClass]; } $refClass = new ReflectionClass($enumClass); $type = new MyCLabsEnumType($enumClass, $this->getTypeName($refClass)); - return $this->cacheByName[$type->name] = $this->cache[$enumClass] = $type; + return $this->cacheByName[$type->name] = $this->cacheByClass[$enumClass] = $type; } private function getTypeName(ReflectionClass $refClass): string @@ -154,23 +156,18 @@ public function mapNameToType(string $typeName): NamedType&\GraphQL\Type\Definit */ private function getNameToClassMapping(): array { - if ($this->nameToClassMapping === null) { - $this->nameToClassMapping = $this->cacheService->get('myclabsenum_name_to_class', function () { - $nameToClassMapping = []; - foreach ($this->namespaces as $ns) { - /** @var class-string $className */ - foreach ($ns->getClassList() as $className => $classRef) { - if (! $classRef->isSubclassOf(Enum::class)) { - continue; - } - - $nameToClassMapping[$this->getTypeName($classRef)] = $className; - } + $this->nameToClassMapping ??= $this->classFinderComputedCache->compute( + $this->classFinder, + 'myclabsenum_name_to_class', + function (ReflectionClass $classReflection): array|null { + if (! $classReflection->isSubclassOf(Enum::class)) { + return null; } - return $nameToClassMapping; - }); - } + return [$this->getTypeName($classReflection) => $classReflection->getName()]; + }, + static fn (array $entries) => array_merge(...array_values(array_filter($entries))), + ); return $this->nameToClassMapping; } diff --git a/src/Mappers/Root/RootTypeMapperFactoryContext.php b/src/Mappers/Root/RootTypeMapperFactoryContext.php index a5e1c1cb11..7786d6fdcf 100644 --- a/src/Mappers/Root/RootTypeMapperFactoryContext.php +++ b/src/Mappers/Root/RootTypeMapperFactoryContext.php @@ -7,11 +7,13 @@ use Psr\Container\ContainerInterface; use Psr\SimpleCache\CacheInterface; use TheCodingMachine\GraphQLite\AnnotationReader; +use TheCodingMachine\GraphQLite\Cache\ClassBoundCache; +use TheCodingMachine\GraphQLite\Discovery\Cache\ClassFinderComputedCache; +use TheCodingMachine\GraphQLite\Discovery\ClassFinder; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapperInterface; use TheCodingMachine\GraphQLite\NamingStrategyInterface; use TheCodingMachine\GraphQLite\TypeRegistry; use TheCodingMachine\GraphQLite\Types\TypeResolver; -use TheCodingMachine\GraphQLite\Utils\Namespaces\NS; /** * A context class containing a number of classes created on the fly by SchemaFactory. @@ -19,11 +21,6 @@ */ final class RootTypeMapperFactoryContext { - /** - * Constructor - * - * @param iterable $typeNamespaces - */ public function __construct( private readonly AnnotationReader $annotationReader, private readonly TypeResolver $typeResolver, @@ -32,9 +29,9 @@ public function __construct( private readonly RecursiveTypeMapperInterface $recursiveTypeMapper, private readonly ContainerInterface $container, private readonly CacheInterface $cache, - private readonly iterable $typeNamespaces, - private readonly int|null $globTTL, - private readonly int|null $mapTTL = null, + private readonly ClassFinder $classFinder, + private readonly ClassFinderComputedCache $classFinderComputedCache, + private readonly ClassBoundCache $classBoundCache, ) { } @@ -73,19 +70,18 @@ public function getCache(): CacheInterface return $this->cache; } - /** @return iterable */ - public function getTypeNamespaces(): iterable + public function getClassFinder(): ClassFinder { - return $this->typeNamespaces; + return $this->classFinder; } - public function getGlobTTL(): int|null + public function getClassFinderComputedCache(): ClassFinderComputedCache { - return $this->globTTL; + return $this->classFinderComputedCache; } - public function getMapTTL(): int|null + public function getClassBoundCache(): ClassBoundCache { - return $this->mapTTL; + return $this->classBoundCache; } } diff --git a/src/Mappers/StaticClassListTypeMapper.php b/src/Mappers/StaticClassListTypeMapper.php deleted file mode 100644 index f5fb0139b0..0000000000 --- a/src/Mappers/StaticClassListTypeMapper.php +++ /dev/null @@ -1,93 +0,0 @@ -> - */ - private array|null $classes = null; - - /** @param array $classList The list of classes to analyze. */ - public function __construct( - private array $classList, - TypeGenerator $typeGenerator, - InputTypeGenerator $inputTypeGenerator, - InputTypeUtils $inputTypeUtils, - ContainerInterface $container, - AnnotationReader $annotationReader, - NamingStrategyInterface $namingStrategy, - RecursiveTypeMapperInterface $recursiveTypeMapper, - CacheInterface $cache, - int|null $globTTL = 2, - int|null $mapTTL = null, - ClassBoundCacheContractFactoryInterface|null $classBoundCacheContractFactory = null, - ) { - $cachePrefix = str_replace( - ['\\', '{', '}', '(', ')', '/', '@', ':'], - '_', - implode('_', $classList), - ); - - parent::__construct( - $cachePrefix, - $typeGenerator, - $inputTypeGenerator, - $inputTypeUtils, - $container, - $annotationReader, - $namingStrategy, - $recursiveTypeMapper, - $cache, - $globTTL, - $mapTTL, - $classBoundCacheContractFactory, - ); - } - - /** - * Returns the array of globbed classes. - * Only instantiable classes are returned. - * - * @return array> Key: fully qualified class name - */ - protected function getClassList(): array - { - if ($this->classes === null) { - $this->classes = []; - foreach ($this->classList as $className) { - if (! class_exists($className) && ! interface_exists($className)) { - throw new GraphQLRuntimeException('Could not find class "' . $className . '"'); - } - $this->classes[$className] = new ReflectionClass($className); - } - } - - return $this->classes; - } -} diff --git a/src/Mappers/StaticClassListTypeMapperFactory.php b/src/Mappers/StaticClassListTypeMapperFactory.php index 774c59c2b7..09fca187ae 100644 --- a/src/Mappers/StaticClassListTypeMapperFactory.php +++ b/src/Mappers/StaticClassListTypeMapperFactory.php @@ -4,18 +4,19 @@ namespace TheCodingMachine\GraphQLite\Mappers; +use TheCodingMachine\GraphQLite\Discovery\StaticClassFinder; use TheCodingMachine\GraphQLite\FactoryContext; use TheCodingMachine\GraphQLite\InputTypeUtils; /** - * A type mapper that is passed the list of classes that it must scan (unlike the GlobTypeMapper that find those automatically). + * A type mapper that is passed the list of classes that it must scan. */ final class StaticClassListTypeMapperFactory implements TypeMapperFactoryInterface { /** * StaticClassListTypeMapperFactory constructor. * - * @param array $classList The list of classes to be scanned. + * @param array $classList The list of classes to be scanned. */ public function __construct( private array $classList, @@ -26,8 +27,8 @@ public function create(FactoryContext $context): TypeMapperInterface { $inputTypeUtils = new InputTypeUtils($context->getAnnotationReader(), $context->getNamingStrategy()); - return new StaticClassListTypeMapper( - $this->classList, + return new ClassFinderTypeMapper( + new StaticClassFinder($this->classList), $context->getTypeGenerator(), $context->getInputTypeGenerator(), $inputTypeUtils, @@ -35,10 +36,7 @@ public function create(FactoryContext $context): TypeMapperInterface $context->getAnnotationReader(), $context->getNamingStrategy(), $context->getRecursiveTypeMapper(), - $context->getCache(), - $context->getGlobTTL(), - $context->getMapTTL(), - $context->getClassBoundCacheContractFactory(), + $context->getClassFinderComputedCache(), ); } } diff --git a/src/Reflection/CachedDocBlockFactory.php b/src/Reflection/CachedDocBlockFactory.php deleted file mode 100644 index 978e1e344d..0000000000 --- a/src/Reflection/CachedDocBlockFactory.php +++ /dev/null @@ -1,133 +0,0 @@ - */ - private array $docBlockArrayCache = []; - /** @var array */ - private array $contextArrayCache = []; - private ContextFactory $contextFactory; - - /** @param CacheInterface $cache The cache we fetch data from. Note this is a SAFE cache. It does not need to be purged. */ - public function __construct(private readonly CacheInterface $cache, DocBlockFactoryInterface|null $docBlockFactory = null) - { - $this->docBlockFactory = $docBlockFactory ?: DocBlockFactory::createInstance(); - $this->contextFactory = new ContextFactory(); - } - - /** - * Fetches a DocBlock object from a ReflectionMethod - * - * @throws InvalidArgumentException - */ - public function getDocBlock(ReflectionMethod|ReflectionProperty $reflector): DocBlock - { - $key = 'docblock_' . md5($reflector->getDeclaringClass()->getName() . '::' . $reflector->getName() . '::' . $reflector::class); - if (isset($this->docBlockArrayCache[$key])) { - return $this->docBlockArrayCache[$key]; - } - - $fileName = $reflector->getDeclaringClass()->getFileName(); - assert(is_string($fileName)); - - $cacheItem = $this->cache->get($key); - if ($cacheItem !== null) { - [ - 'time' => $time, - 'docblock' => $docBlock, - ] = $cacheItem; - - if (filemtime($fileName) === $time) { - $this->docBlockArrayCache[$key] = $docBlock; - - return $docBlock; - } - } - - $docBlock = $this->doGetDocBlock($reflector); - - $this->cache->set($key, [ - 'time' => filemtime($fileName), - 'docblock' => $docBlock, - ]); - $this->docBlockArrayCache[$key] = $docBlock; - - return $docBlock; - } - - private function doGetDocBlock(ReflectionMethod|ReflectionProperty $reflector): DocBlock - { - $docComment = $reflector->getDocComment() ?: '/** */'; - - $refClass = $reflector->getDeclaringClass(); - $refClassName = $refClass->getName(); - - if (! isset($this->contextArrayCache[$refClassName])) { - $this->contextArrayCache[$refClassName] = $this->contextFactory->createFromReflector($reflector); - } - - return $this->docBlockFactory->create($docComment, $this->contextArrayCache[$refClassName]); - } - - /** @param ReflectionClass $reflectionClass */ - public function getContextFromClass(ReflectionClass $reflectionClass): Context - { - $className = $reflectionClass->getName(); - if (isset($this->contextArrayCache[$className])) { - return $this->contextArrayCache[$className]; - } - - $key = 'docblockcontext_' . md5($className); - - $fileName = $reflectionClass->getFileName(); - assert(is_string($fileName)); - - $cacheItem = $this->cache->get($key); - if ($cacheItem !== null) { - [ - 'time' => $time, - 'context' => $context, - ] = $cacheItem; - - if (filemtime($fileName) === $time) { - $this->contextArrayCache[$className] = $context; - - return $context; - } - } - - $context = $this->contextFactory->createFromReflector($reflectionClass); - - $this->cache->set($key, [ - 'time' => filemtime($fileName), - 'context' => $context, - ]); - - $this->contextArrayCache[$className] = $context; - return $context; - } -} diff --git a/src/Reflection/DocBlock/CachedDocBlockFactory.php b/src/Reflection/DocBlock/CachedDocBlockFactory.php new file mode 100644 index 0000000000..b6fb1a8a09 --- /dev/null +++ b/src/Reflection/DocBlock/CachedDocBlockFactory.php @@ -0,0 +1,52 @@ +getDeclaringClass(); + + return $this->classBoundCache->get( + $class, + fn () => $this->docBlockFactory->create($reflector, $context ?? $this->createContext($class)), + 'reflection.docBlock.' . md5($reflector::class . '.' . $reflector->getName()), + ); + } + + public function createContext(ReflectionClass|ReflectionMethod|ReflectionProperty|ReflectionClassConstant $reflector): Context + { + $reflector = $reflector instanceof ReflectionClass ? $reflector : $reflector->getDeclaringClass(); + + return $this->classBoundCache->get( + $reflector, + fn () => $this->docBlockFactory->createContext($reflector), + 'reflection.docBlockContext', + ); + } +} diff --git a/src/Reflection/DocBlock/DocBlockFactory.php b/src/Reflection/DocBlock/DocBlockFactory.php new file mode 100644 index 0000000000..5a0009a5f3 --- /dev/null +++ b/src/Reflection/DocBlock/DocBlockFactory.php @@ -0,0 +1,25 @@ +getDocComment() ?: '/** */'; + + return $this->docBlockFactory->create( + $docblock, + $context ?? $this->createContext($reflector), + ); + } + + public function createContext(ReflectionClass|ReflectionMethod|ReflectionProperty|ReflectionClassConstant $reflector): Context + { + return $this->contextFactory->createFromReflector($reflector); + } +} diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index 029c5eada8..3991341b3f 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -5,17 +5,27 @@ namespace TheCodingMachine\GraphQLite; use GraphQL\Type\SchemaConfig; +use Kcs\ClassFinder\FileFinder\CachedFileFinder; +use Kcs\ClassFinder\FileFinder\DefaultFileFinder; use Kcs\ClassFinder\Finder\ComposerFinder; use Kcs\ClassFinder\Finder\FinderInterface; use MyCLabs\Enum\Enum; use PackageVersions\Versions; use Psr\Container\ContainerInterface; use Psr\SimpleCache\CacheInterface; +use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Adapter\Psr16Adapter; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; -use TheCodingMachine\GraphQLite\Cache\ClassBoundCacheContractFactoryInterface; +use TheCodingMachine\GraphQLite\Cache\ClassBoundCache; +use TheCodingMachine\GraphQLite\Cache\FilesSnapshot; +use TheCodingMachine\GraphQLite\Cache\SnapshotClassBoundCache; +use TheCodingMachine\GraphQLite\Discovery\Cache\HardClassFinderComputedCache; +use TheCodingMachine\GraphQLite\Discovery\Cache\SnapshotClassFinderComputedCache; +use TheCodingMachine\GraphQLite\Discovery\ClassFinder; +use TheCodingMachine\GraphQLite\Discovery\KcsClassFinder; +use TheCodingMachine\GraphQLite\Discovery\StaticClassFinder; +use TheCodingMachine\GraphQLite\Mappers\ClassFinderTypeMapper; use TheCodingMachine\GraphQLite\Mappers\CompositeTypeMapper; -use TheCodingMachine\GraphQLite\Mappers\GlobTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Parameters\ContainerParameterHandler; use TheCodingMachine\GraphQLite\Mappers\Parameters\InjectUserParameterHandler; use TheCodingMachine\GraphQLite\Mappers\Parameters\ParameterMiddlewareInterface; @@ -46,7 +56,8 @@ use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewarePipe; use TheCodingMachine\GraphQLite\Middlewares\SecurityFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\SecurityInputFieldMiddleware; -use TheCodingMachine\GraphQLite\Reflection\CachedDocBlockFactory; +use TheCodingMachine\GraphQLite\Reflection\DocBlock\CachedDocBlockFactory; +use TheCodingMachine\GraphQLite\Reflection\DocBlock\PhpDocumentorDocBlockFactory; use TheCodingMachine\GraphQLite\Security\AuthenticationServiceInterface; use TheCodingMachine\GraphQLite\Security\AuthorizationServiceInterface; use TheCodingMachine\GraphQLite\Security\FailAuthenticationService; @@ -56,13 +67,14 @@ use TheCodingMachine\GraphQLite\Types\InputTypeValidatorInterface; use TheCodingMachine\GraphQLite\Types\TypeResolver; use TheCodingMachine\GraphQLite\Utils\NamespacedCache; -use TheCodingMachine\GraphQLite\Utils\Namespaces\NamespaceFactory; -use function array_map; use function array_reverse; use function class_exists; use function md5; use function substr; +use function trigger_error; + +use const E_USER_DEPRECATED; /** * A class to help getting started with GraphQLite. @@ -70,14 +82,8 @@ */ class SchemaFactory { - public const GLOB_CACHE_SECONDS = 2; - - /** @var array */ - private array $controllerNamespaces = []; - - /** @var array */ - private array $typeNamespaces = []; + private array $namespaces = []; /** @var QueryProviderInterface[] */ private array $queryProviders = []; @@ -105,11 +111,11 @@ class SchemaFactory private NamingStrategyInterface|null $namingStrategy = null; - private FinderInterface|null $finder = null; + private ClassFinder|FinderInterface|null $finder = null; private SchemaConfig|null $schemaConfig = null; - private int|null $globTTL = self::GLOB_CACHE_SECONDS; + private bool $devMode = true; /** @var array */ private array $fieldMiddlewares = []; @@ -121,27 +127,50 @@ class SchemaFactory private string $cacheNamespace; - public function __construct(private readonly CacheInterface $cache, private readonly ContainerInterface $container, private ClassBoundCacheContractFactoryInterface|null $classBoundCacheContractFactory = null) - { + public function __construct( + private readonly CacheInterface $cache, + private readonly ContainerInterface $container, + private ClassBoundCache|null $classBoundCache = null, + ) { $this->cacheNamespace = substr(md5(Versions::getVersion('thecodingmachine/graphqlite')), 0, 8); } /** * Registers a namespace that can contain GraphQL controllers. + * + * @deprecated Using SchemaFactory::addControllerNamespace() is deprecated in favor of SchemaFactory::addNamespace() */ public function addControllerNamespace(string $namespace): self { - $this->controllerNamespaces[] = $namespace; + trigger_error( + 'Using SchemaFactory::addControllerNamespace() is deprecated in favor of SchemaFactory::addNamespace().', + E_USER_DEPRECATED, + ); - return $this; + return $this->addNamespace($namespace); } /** * Registers a namespace that can contain GraphQL types. + * + * @deprecated Using SchemaFactory::addTypeNamespace() is deprecated in favor of SchemaFactory::addNamespace() */ public function addTypeNamespace(string $namespace): self { - $this->typeNamespaces[] = $namespace; + trigger_error( + 'Using SchemaFactory::addTypeNamespace() is deprecated in favor of SchemaFactory::addNamespace().', + E_USER_DEPRECATED, + ); + + return $this->addNamespace($namespace); + } + + /** + * Registers a namespace that can contain GraphQL types or controllers. + */ + public function addNamespace(string $namespace): self + { + $this->namespaces[] = $namespace; return $this; } @@ -241,55 +270,52 @@ public function setSchemaConfig(SchemaConfig $schemaConfig): self return $this; } - public function setFinder(FinderInterface $finder): self + public function setFinder(ClassFinder|FinderInterface $finder): self { $this->finder = $finder; return $this; } - /** - * Sets the time to live time of the cache for annotations in files. - * By default this is set to 2 seconds which is ok for development environments. - * Set this to "null" (i.e. infinity) for production environments. - */ + /** @deprecated setGlobTTL(null) or setGlobTTL(0) is equivalent to prodMode(), and any other values are equivalent to devMode() */ public function setGlobTTL(int|null $globTTL): self { - $this->globTTL = $globTTL; + trigger_error( + 'Using SchemaFactory::setGlobTTL() is deprecated in favor of SchemaFactory::devMode() and SchemaFactory::prodMode().', + E_USER_DEPRECATED, + ); - return $this; + return $globTTL ? $this->devMode() : $this->prodMode(); } /** - * Set a custom ClassBoundCacheContractFactory. - * This is used to create CacheContracts that store reflection results. - * Set this to "null" to use the default fallback factory. + * Set a custom class bound cache. By default in dev mode it looks at file modification times. */ - public function setClassBoundCacheContractFactory(ClassBoundCacheContractFactoryInterface|null $classBoundCacheContractFactory): self + public function setClassBoundCache(ClassBoundCache|null $classBoundCache): self { - $this->classBoundCacheContractFactory = $classBoundCacheContractFactory; + $this->classBoundCache = $classBoundCache; return $this; } /** * Sets GraphQLite in "prod" mode (cache settings optimized for best performance). - * - * This is a shortcut for `$schemaFactory->setGlobTTL(null)` */ public function prodMode(): self { - return $this->setGlobTTL(null); + $this->devMode = false; + + return $this; } /** * Sets GraphQLite in "dev" mode (this is the default mode: cache settings optimized for best developer experience). - * - * This is a shortcut for `$schemaFactory->setGlobTTL(2)` */ public function devMode(): self { - return $this->setGlobTTL(self::GLOB_CACHE_SECONDS); + $this->devMode = true; + + return $this; } /** @@ -331,16 +357,20 @@ public function createSchema(): Schema $authorizationService = $this->authorizationService ?: new FailAuthorizationService(); $typeResolver = new TypeResolver(); $namespacedCache = new NamespacedCache($this->cache); - $cachedDocBlockFactory = new CachedDocBlockFactory($namespacedCache); + $classBoundCache = $this->classBoundCache ?: new SnapshotClassBoundCache( + $this->cache, + $this->devMode ? FilesSnapshot::forClass(...) : FilesSnapshot::alwaysUnchanged(...), + ); + $docBlockFactory = new CachedDocBlockFactory( + $classBoundCache, + PhpDocumentorDocBlockFactory::default(), + ); $namingStrategy = $this->namingStrategy ?: new NamingStrategy(); $typeRegistry = new TypeRegistry(); - $finder = $this->finder ?? new ComposerFinder(); - - $namespaceFactory = new NamespaceFactory($namespacedCache, $finder, $this->globTTL); - $nsList = array_map( - static fn (string $namespace) => $namespaceFactory->createNamespace($namespace), - $this->typeNamespaces, - ); + $classFinder = $this->createClassFinder(); + $classFinderComputedCache = $this->devMode ? + new SnapshotClassFinderComputedCache($this->cache) : + new HardClassFinderComputedCache($this->cache); $expressionLanguage = $this->expressionLanguage ?: new ExpressionLanguage($symfonyCache); $expressionLanguage->registerProvider(new SecurityExpressionLanguageProvider()); @@ -371,11 +401,11 @@ public function createSchema(): Schema $errorRootTypeMapper = new FinalRootTypeMapper($recursiveTypeMapper); $rootTypeMapper = new BaseTypeMapper($errorRootTypeMapper, $recursiveTypeMapper, $topRootTypeMapper); - $rootTypeMapper = new EnumTypeMapper($rootTypeMapper, $annotationReader, $symfonyCache, $nsList); + $rootTypeMapper = new EnumTypeMapper($rootTypeMapper, $annotationReader, $docBlockFactory, $classFinder, $classFinderComputedCache); if (class_exists(Enum::class)) { // Annotation support - deprecated - $rootTypeMapper = new MyCLabsEnumTypeMapper($rootTypeMapper, $annotationReader, $symfonyCache, $nsList); + $rootTypeMapper = new MyCLabsEnumTypeMapper($rootTypeMapper, $annotationReader, $classFinder, $classFinderComputedCache); } if (! empty($this->rootTypeMapperFactories)) { @@ -387,8 +417,9 @@ public function createSchema(): Schema $recursiveTypeMapper, $this->container, $namespacedCache, - $nsList, - $this->globTTL, + classFinder: $classFinder, + classFinderComputedCache: $classFinderComputedCache, + classBoundCache: $classBoundCache, ); $reversedRootTypeMapperFactories = array_reverse($this->rootTypeMapperFactories); @@ -410,7 +441,7 @@ public function createSchema(): Schema $recursiveTypeMapper, $argumentResolver, $typeResolver, - $cachedDocBlockFactory, + $docBlockFactory, $namingStrategy, $topRootTypeMapper, $parameterMiddlewarePipe, @@ -431,9 +462,9 @@ public function createSchema(): Schema $inputTypeUtils = new InputTypeUtils($annotationReader, $namingStrategy); $inputTypeGenerator = new InputTypeGenerator($inputTypeUtils, $fieldsBuilder, $this->inputTypeValidator); - foreach ($nsList as $ns) { - $compositeTypeMapper->addTypeMapper(new GlobTypeMapper( - $ns, + if ($this->namespaces) { + $compositeTypeMapper->addTypeMapper(new ClassFinderTypeMapper( + $classFinder, $typeGenerator, $inputTypeGenerator, $inputTypeUtils, @@ -441,9 +472,7 @@ public function createSchema(): Schema $annotationReader, $namingStrategy, $recursiveTypeMapper, - $namespacedCache, - $this->globTTL, - classBoundCacheContractFactory: $this->classBoundCacheContractFactory, + $classFinderComputedCache, )); } @@ -460,8 +489,9 @@ classBoundCacheContractFactory: $this->classBoundCacheContractFactory, $this->container, $namespacedCache, $this->inputTypeValidator, - $this->globTTL, - classBoundCacheContractFactory: $this->classBoundCacheContractFactory, + classFinder: $classFinder, + classFinderComputedCache: $classFinderComputedCache, + classBoundCache: $classBoundCache, ); } @@ -469,8 +499,8 @@ classBoundCacheContractFactory: $this->classBoundCacheContractFactory, $this->typeMappers[] = $typeMapperFactory->create($context); } - if (empty($this->typeNamespaces) && empty($this->typeMappers)) { - throw new GraphQLRuntimeException('Cannot create schema: no namespace for types found (You must call the SchemaFactory::addTypeNamespace() at least once).'); + if (empty($this->namespaces) && empty($this->typeMappers)) { + throw new GraphQLRuntimeException('Cannot create schema: no namespace for types found (You must call the SchemaFactory::addNamespace() at least once).'); } foreach ($this->typeMappers as $typeMapper) { @@ -480,15 +510,14 @@ classBoundCacheContractFactory: $this->classBoundCacheContractFactory, $compositeTypeMapper->addTypeMapper(new PorpaginasTypeMapper($recursiveTypeMapper)); $queryProviders = []; - foreach ($this->controllerNamespaces as $controllerNamespace) { + + if ($this->namespaces) { $queryProviders[] = new GlobControllerQueryProvider( - $controllerNamespace, $fieldsBuilder, $this->container, $annotationReader, - $namespacedCache, - $finder, - $this->globTTL, + $classFinder, + $classFinderComputedCache, ); } @@ -501,11 +530,41 @@ classBoundCacheContractFactory: $this->classBoundCacheContractFactory, } if ($queryProviders === []) { - throw new GraphQLRuntimeException('Cannot create schema: no namespace for controllers found (You must call the SchemaFactory::addControllerNamespace() at least once).'); + throw new GraphQLRuntimeException('Cannot create schema: no namespace for controllers found (You must call the SchemaFactory::addNamespace() at least once).'); } $aggregateQueryProvider = new AggregateQueryProvider($queryProviders); return new Schema($aggregateQueryProvider, $recursiveTypeMapper, $typeResolver, $topRootTypeMapper, $this->schemaConfig); } + + private function createClassFinder(): ClassFinder + { + if ($this->finder instanceof ClassFinder) { + return $this->finder; + } + + // When no namespaces are specified, class finder uses all available namespaces to discover classes. + // While this is technically okay, it doesn't follow SchemaFactory's semantics that allow it's + // users to manually specify classes (see SchemaFactory::testCreateSchemaOnlyWithFactories()), + // without having to specify namespaces to glob. This solves it by providing an empty iterator. + if (! $this->namespaces) { + return new StaticClassFinder([]); + } + + $finder = (clone ($this->finder ?? new ComposerFinder())); + + // Because this finder may be iterated more than once, we need to make + // sure that the filesystem is only hit once in the lifetime of the application, + // as that may be expensive for larger projects or non-native filesystems. + if ($finder instanceof ComposerFinder) { + $finder = $finder->withFileFinder(new CachedFileFinder(new DefaultFileFinder(), new ArrayAdapter())); + } + + foreach ($this->namespaces as $namespace) { + $finder = $finder->inNamespace($namespace); + } + + return new KcsClassFinder($finder); + } } diff --git a/src/Utils/Namespaces/NS.php b/src/Utils/Namespaces/NS.php deleted file mode 100644 index f1c51f3a18..0000000000 --- a/src/Utils/Namespaces/NS.php +++ /dev/null @@ -1,104 +0,0 @@ -> - */ - private array|null $classes = null; - - /** @param string $namespace The namespace that contains the GraphQL types (they must have a `@Type` annotation) */ - public function __construct( - private readonly string $namespace, - private readonly CacheInterface $cache, - private readonly FinderInterface $finder, - private readonly int|null $globTTL, - ) - { - } - - /** - * Returns the array of globbed classes. - * Only instantiable classes are returned. - * - * @return array> Key: fully qualified class name - */ - public function getClassList(): array - { - if ($this->classes === null) { - $cacheKey = 'GraphQLite_NS_' . preg_replace('/[\/{}()\\\\@:]/', '', $this->namespace); - try { - $classes = $this->cache->get($cacheKey); - if ($classes !== null) { - foreach ($classes as $class) { - if ( - ! class_exists($class, false) && - ! interface_exists($class, false) && - ! trait_exists($class, false) - ) { - // assume the cache is invalid - throw new class extends Exception implements CacheException { - }; - } - - $this->classes[$class] = new ReflectionClass($class); - } - } - } catch (CacheException | InvalidArgumentException | ReflectionException) { - $this->classes = null; - } - - if ($this->classes === null) { - $this->classes = []; - /** @var class-string $className */ - /** @var ReflectionClass $reflector */ - foreach ((clone $this->finder)->inNamespace($this->namespace) as $className => $reflector) { - if (! ($reflector instanceof ReflectionClass)) { - continue; - } - - $this->classes[$className] = $reflector; - } - try { - $this->cache->set($cacheKey, array_keys($this->classes), $this->globTTL); - } catch (InvalidArgumentException) { - // @ignoreException - } - } - } - - return $this->classes; - } - - public function getNamespace(): string - { - return $this->namespace; - } -} diff --git a/src/Utils/Namespaces/NamespaceFactory.php b/src/Utils/Namespaces/NamespaceFactory.php deleted file mode 100644 index 719c80bb22..0000000000 --- a/src/Utils/Namespaces/NamespaceFactory.php +++ /dev/null @@ -1,29 +0,0 @@ -cache, clone $this->finder, $this->globTTL); - } -} diff --git a/tests/AbstractQueryProvider.php b/tests/AbstractQueryProvider.php index 129068079c..8e8c09fe72 100644 --- a/tests/AbstractQueryProvider.php +++ b/tests/AbstractQueryProvider.php @@ -10,6 +10,8 @@ use GraphQL\Type\Definition\OutputType; use GraphQL\Type\Definition\Type; use GraphQL\Type\Schema; +use Kcs\ClassFinder\FileFinder\CachedFileFinder; +use Kcs\ClassFinder\FileFinder\DefaultFileFinder; use Kcs\ClassFinder\Finder\ComposerFinder; use phpDocumentor\Reflection\TypeResolver as PhpDocumentorTypeResolver; use PHPUnit\Framework\TestCase; @@ -18,9 +20,17 @@ use Symfony\Component\Cache\Adapter\Psr16Adapter; use Symfony\Component\Cache\Psr16Cache; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use TheCodingMachine\GraphQLite\Cache\ClassBoundCache; +use TheCodingMachine\GraphQLite\Cache\FilesSnapshot; +use TheCodingMachine\GraphQLite\Cache\SnapshotClassBoundCache; use TheCodingMachine\GraphQLite\Containers\BasicAutoWiringContainer; use TheCodingMachine\GraphQLite\Containers\EmptyContainer; use TheCodingMachine\GraphQLite\Containers\LazyContainer; +use TheCodingMachine\GraphQLite\Discovery\Cache\ClassFinderComputedCache; +use TheCodingMachine\GraphQLite\Discovery\Cache\HardClassFinderComputedCache; +use TheCodingMachine\GraphQLite\Discovery\ClassFinder; +use TheCodingMachine\GraphQLite\Discovery\StaticClassFinder; +use TheCodingMachine\GraphQLite\Discovery\KcsClassFinder; use TheCodingMachine\GraphQLite\Fixtures\Mocks\MockResolvableInputObjectType; use TheCodingMachine\GraphQLite\Fixtures\TestObject; use TheCodingMachine\GraphQLite\Fixtures\TestObject2; @@ -45,7 +55,9 @@ use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewarePipe; use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewarePipe; use TheCodingMachine\GraphQLite\Middlewares\SecurityFieldMiddleware; -use TheCodingMachine\GraphQLite\Reflection\CachedDocBlockFactory; +use TheCodingMachine\GraphQLite\Reflection\DocBlock\CachedDocBlockFactory; +use TheCodingMachine\GraphQLite\Reflection\DocBlock\DocBlockFactory; +use TheCodingMachine\GraphQLite\Reflection\DocBlock\PhpDocumentorDocBlockFactory; use TheCodingMachine\GraphQLite\Security\SecurityExpressionLanguageProvider; use TheCodingMachine\GraphQLite\Security\VoidAuthenticationService; use TheCodingMachine\GraphQLite\Security\VoidAuthorizationService; @@ -54,7 +66,6 @@ use TheCodingMachine\GraphQLite\Types\MutableObjectType; use TheCodingMachine\GraphQLite\Types\ResolvableMutableInputInterface; use TheCodingMachine\GraphQLite\Types\TypeResolver; -use TheCodingMachine\GraphQLite\Utils\Namespaces\NamespaceFactory; abstract class AbstractQueryProvider extends TestCase { @@ -73,7 +84,6 @@ abstract class AbstractQueryProvider extends TestCase private $typeRegistry; private $parameterMiddlewarePipe; private $rootTypeMapper; - private $namespaceFactory; protected function getTestObjectType(): MutableObjectType { @@ -265,13 +275,21 @@ protected function getParameterMiddlewarePipe(): ParameterMiddlewarePipe return $this->parameterMiddlewarePipe; } - protected function getCachedDocBlockFactory(): CachedDocBlockFactory + protected function getDocBlockFactory(): DocBlockFactory + { + return new CachedDocBlockFactory( + $this->getClassBoundCache(), + PhpDocumentorDocBlockFactory::default(), + ); + } + + private function getClassBoundCache(): ClassBoundCache { $arrayAdapter = new ArrayAdapter(); $arrayAdapter->setLogger(new ExceptionLogger()); $psr16Cache = new Psr16Cache($arrayAdapter); - return new CachedDocBlockFactory($psr16Cache); + return new SnapshotClassBoundCache($psr16Cache, FilesSnapshot::alwaysUnchanged(...)); } protected function buildFieldsBuilder(): FieldsBuilder @@ -307,7 +325,7 @@ protected function buildFieldsBuilder(): FieldsBuilder $this->getTypeMapper(), $this->getArgumentResolver(), $this->getTypeResolver(), - $this->getCachedDocBlockFactory(), + $this->getDocBlockFactory(), new NamingStrategy(), $this->buildRootTypeMapper(), $parameterMiddlewarePipe, @@ -350,15 +368,16 @@ protected function buildRootTypeMapper(): RootTypeMapperInterface $rootTypeMapper = new MyCLabsEnumTypeMapper( $rootTypeMapper, $this->getAnnotationReader(), - $arrayAdapter, - [], + new StaticClassFinder([]), + new HardClassFinderComputedCache(new Psr16Cache($arrayAdapter)), ); $rootTypeMapper = new EnumTypeMapper( $rootTypeMapper, $this->getAnnotationReader(), - $arrayAdapter, - [], + $this->getDocBlockFactory(), + new StaticClassFinder([]), + new HardClassFinderComputedCache(new Psr16Cache($arrayAdapter)), ); $rootTypeMapper = new CompoundTypeMapper( @@ -449,15 +468,28 @@ protected static function resolveType(string $type): \phpDocumentor\Reflection\T return (new PhpDocumentorTypeResolver())->resolve($type); } - protected function getNamespaceFactory(): NamespaceFactory + protected function getClassFinder(array|string $namespaces): ClassFinder { - if ($this->namespaceFactory === null) { - $arrayAdapter = new ArrayAdapter(); - $arrayAdapter->setLogger(new ExceptionLogger()); - $psr16Cache = new Psr16Cache($arrayAdapter); + $finder = new ComposerFinder(); - $this->namespaceFactory = new NamespaceFactory($psr16Cache, new ComposerFinder()); + foreach ((array) $namespaces as $namespace) { + $finder->inNamespace($namespace); } - return $this->namespaceFactory; + + $arrayAdapter = new ArrayAdapter(); + $arrayAdapter->setLogger(new ExceptionLogger()); + + $finder = $finder->withFileFinder(new CachedFileFinder(new DefaultFileFinder(), $arrayAdapter)); + + return new KcsClassFinder($finder); + } + + protected function getClassFinderComputedCache(): ClassFinderComputedCache + { + $arrayAdapter = new ArrayAdapter(); + $arrayAdapter->setLogger(new ExceptionLogger()); + $psr16Cache = new Psr16Cache($arrayAdapter); + + return new HardClassFinderComputedCache($psr16Cache); } } diff --git a/tests/Cache/FilesSnapshotTest.php b/tests/Cache/FilesSnapshotTest.php new file mode 100644 index 0000000000..f45aa2c75e --- /dev/null +++ b/tests/Cache/FilesSnapshotTest.php @@ -0,0 +1,77 @@ +changed()); + + // Make sure it serializes properly + /** @var FilesSnapshot $snapshot */ + $snapshot = unserialize(serialize($snapshot)); + + self::assertFalse($snapshot->changed()); + + $this->touch($fooReflection->getFileName()); + + self::assertTrue($snapshot->changed()); + } + + public function testDoesNotTrackChangesInSuperTypesWithoutUsingInheritance(): void + { + $fooReflection = new \ReflectionClass(FooType::class); + $snapshot = FilesSnapshot::forClass($fooReflection); + + self::assertFalse($snapshot->changed()); + + $this->touch($fooReflection->getParentClass()->getFileName()); + + self::assertFalse($snapshot->changed()); + } + + public function testTracksChangesInSuperTypesUsingInheritance(): void + { + $fooReflection = new \ReflectionClass(FooType::class); + $snapshot = FilesSnapshot::forClass($fooReflection, true); + + self::assertFalse($snapshot->changed()); + + $this->touch($fooReflection->getParentClass()->getFileName()); + + self::assertTrue($snapshot->changed()); + } + + public function testTracksChangesInFile(): void + { + $fileName = (new \ReflectionClass(FooType::class))->getFileName(); + $snapshot = FilesSnapshot::for([$fileName]); + + self::assertFalse($snapshot->changed()); + + $this->touch($fileName); + + self::assertTrue($snapshot->changed()); + } + + private function touch(string $fileName): void + { + touch($fileName, filemtime($fileName) + 1); + clearstatcache(); + } +} \ No newline at end of file diff --git a/tests/Cache/SnapshotClassBoundCacheTest.php b/tests/Cache/SnapshotClassBoundCacheTest.php new file mode 100644 index 0000000000..d9e9390e74 --- /dev/null +++ b/tests/Cache/SnapshotClassBoundCacheTest.php @@ -0,0 +1,58 @@ +get($fooReflection, fn () => 'foo_key', 'key', true); + + self::assertSame('foo_key', $fooKeyResult); + self::assertSame('foo_key', $classBoundCache->get($fooReflection, fn () => self::fail('Should not be called.'), 'key', true)); + + $fooDifferentKeyResult = $classBoundCache->get($fooReflection, fn () => 'foo_different_key', 'different_key', true); + + self::assertSame('foo_different_key', $fooDifferentKeyResult); + self::assertSame('foo_different_key', $classBoundCache->get($fooReflection, fn () => self::fail('Should not be called.'), 'different_key', true)); + + $barReflection = new \ReflectionClass(NoTypeAnnotation::class); + $barKeyResult = $classBoundCache->get($barReflection, fn () => 'bar_key', 'key', true); + + self::assertSame('bar_key', $barKeyResult); + self::assertSame('bar_key', $classBoundCache->get($barReflection, fn () => self::fail('Should not be called.'), 'key', true)); + + self::assertCount(3, $arrayCache->getValues()); + + $this->touch($fooReflection->getParentClass()->getFileName()); + + self::assertSame( + 'foo_key_updated', + $classBoundCache->get($fooReflection, fn () => 'foo_key_updated', 'key', true) + ); + } + + private function touch(string $fileName): void + { + touch($fileName, filemtime($fileName) + 1); + clearstatcache(); + } +} \ No newline at end of file diff --git a/tests/Discovery/Cache/HardClassFinderComputedCacheTest.php b/tests/Discovery/Cache/HardClassFinderComputedCacheTest.php new file mode 100644 index 0000000000..407550a0b3 --- /dev/null +++ b/tests/Discovery/Cache/HardClassFinderComputedCacheTest.php @@ -0,0 +1,58 @@ +setLogger(new ExceptionLogger()); + $cache = new Psr16Cache($arrayAdapter); + + $classFinderComputedCache = new HardClassFinderComputedCache($cache); + + [$result] = $classFinderComputedCache->compute( + new StaticClassFinder([ + FooType::class, + FooExtendType::class, + TestType::class, + ]), + 'key', + fn (\ReflectionClass $reflection) => $reflection->getShortName(), + fn (array $entries) => [array_values($entries)], + ); + + $this->assertSame([ + 'FooType', + 'FooExtendType', + 'TestType', + ], $result); + + // Even though the class finder and both functions have changed - the result should still be cached. + // This is useful in production, where code and file structure doesn't change. + [$result] = $classFinderComputedCache->compute( + new StaticClassFinder([]), + 'key', + fn (\ReflectionClass $reflection) => self::fail('Should not be called.'), + fn (array $entries) => self::fail('Should not be called.'), + ); + + $this->assertSame([ + 'FooType', + 'FooExtendType', + 'TestType', + ], $result); + } +} \ No newline at end of file diff --git a/tests/Discovery/Cache/SnapshotClassFinderComputedCacheTest.php b/tests/Discovery/Cache/SnapshotClassFinderComputedCacheTest.php new file mode 100644 index 0000000000..bbf50389d1 --- /dev/null +++ b/tests/Discovery/Cache/SnapshotClassFinderComputedCacheTest.php @@ -0,0 +1,89 @@ +setLogger(new ExceptionLogger()); + $cache = new Psr16Cache($arrayAdapter); + + $classFinderComputedCache = new SnapshotClassFinderComputedCache($cache); + + [$result] = $classFinderComputedCache->compute( + new StaticClassFinder([ + FooType::class, + FooExtendType::class, + TestType::class, + ]), + 'key', + fn (\ReflectionClass $reflection) => $reflection->getShortName(), + fn (array $entries) => [array_values($entries)], + ); + + $this->assertSame([ + 'FooType', + 'FooExtendType', + 'TestType', + ], $result); + + [$result] = $classFinderComputedCache->compute( + new StaticClassFinder([ + FooType::class, + FooExtendType::class, + TestType::class, + ]), + 'key', + fn (\ReflectionClass $reflection) => self::fail('Should not be called.'), + fn (array $entries) => [array_values($entries)], + ); + + $this->assertSame([ + 'FooType', + 'FooExtendType', + 'TestType', + ], $result); + + $this->touch((new \ReflectionClass(FooType::class))->getFileName()); + + [$result] = $classFinderComputedCache->compute( + new StaticClassFinder([ + FooType::class, + TestType::class, + EnumType::class, + ]), + 'key', + fn (\ReflectionClass $reflection) => $reflection->getShortName() . ' Modified', + fn (array $entries) => [array_values($entries)], + ); + + $this->assertSame([ + 'FooType Modified', + 'TestType', + 'EnumType Modified', + ], $result); + } + + private function touch(string $fileName): void + { + touch($fileName, filemtime($fileName) + 1); + clearstatcache(); + } +} \ No newline at end of file diff --git a/tests/Discovery/KcsClassFinderTest.php b/tests/Discovery/KcsClassFinderTest.php new file mode 100644 index 0000000000..589308236c --- /dev/null +++ b/tests/Discovery/KcsClassFinderTest.php @@ -0,0 +1,53 @@ +inNamespace('TheCodingMachine\GraphQLite\Fixtures\Types') + ); + + $finderWithPath = $finder->withPathFilter(fn (string $path) => str_contains($path, 'FooExtendType.php')); + + $this->assertFoundClasses([ + TestFactory::class, + GetterSetterType::class, + FooType::class, + MagicGetterSetterType::class, + FooExtendType::class, + NoTypeAnnotation::class, + AbstractFooType::class, + EnumType::class, + ], $finder); + + $this->assertFoundClasses([ + FooExtendType::class, + ], $finderWithPath); + } + + private function assertFoundClasses(array $expectedClasses, ClassFinder $classFinder): void + { + $result = iterator_to_array($classFinder); + + $this->assertContainsOnlyInstancesOf(\ReflectionClass::class, $result); + $this->assertEqualsCanonicalizing($expectedClasses, array_keys($result)); + } +} \ No newline at end of file diff --git a/tests/Discovery/StaticClassFinderTest.php b/tests/Discovery/StaticClassFinderTest.php new file mode 100644 index 0000000000..51b6a00501 --- /dev/null +++ b/tests/Discovery/StaticClassFinderTest.php @@ -0,0 +1,42 @@ +withPathFilter(fn (string $path) => str_contains($path, 'FooExtendType.php')); + + $this->assertFoundClasses([ + FooType::class, + TestType::class, + FooExtendType::class, + ], $finder); + + $this->assertFoundClasses([ + FooExtendType::class, + ], $finderWithPath); + } + + private function assertFoundClasses(array $expectedClasses, ClassFinder $classFinder): void + { + $result = iterator_to_array($classFinder); + + $this->assertContainsOnlyInstancesOf(\ReflectionClass::class, $result); + $this->assertEqualsCanonicalizing($expectedClasses, array_keys($result)); + } +} \ No newline at end of file diff --git a/tests/FactoryContextTest.php b/tests/FactoryContextTest.php index 9f02729924..3ab131851a 100644 --- a/tests/FactoryContextTest.php +++ b/tests/FactoryContextTest.php @@ -4,8 +4,11 @@ use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Psr16Cache; -use TheCodingMachine\GraphQLite\Cache\ClassBoundCacheContractFactory; +use TheCodingMachine\GraphQLite\Cache\FilesSnapshot; +use TheCodingMachine\GraphQLite\Cache\SnapshotClassBoundCache; use TheCodingMachine\GraphQLite\Containers\EmptyContainer; +use TheCodingMachine\GraphQLite\Discovery\Cache\HardClassFinderComputedCache; +use TheCodingMachine\GraphQLite\Discovery\StaticClassFinder; use TheCodingMachine\GraphQLite\Fixtures\Inputs\Validator; class FactoryContextTest extends AbstractQueryProvider @@ -17,7 +20,9 @@ public function testContext(): void $namingStrategy = new NamingStrategy(); $container = new EmptyContainer(); $arrayCache = new Psr16Cache(new ArrayAdapter()); - $classBoundCacheContractFactory = new ClassBoundCacheContractFactory(); + $classFinder = new StaticClassFinder([]); + $classFinderComputedCache = new HardClassFinderComputedCache($arrayCache); + $classBoundCache = new SnapshotClassBoundCache($arrayCache, FilesSnapshot::alwaysUnchanged(...)); $validator = new Validator(); $context = new FactoryContext( @@ -32,8 +37,9 @@ public function testContext(): void $container, $arrayCache, $validator, - self::GLOB_TTL_SECONDS, - classBoundCacheContractFactory: $classBoundCacheContractFactory, + classFinder: $classFinder, + classFinderComputedCache: $classFinderComputedCache, + classBoundCache: $classBoundCache, ); $this->assertSame($this->getAnnotationReader(), $context->getAnnotationReader()); @@ -47,7 +53,8 @@ classBoundCacheContractFactory: $classBoundCacheContractFactory, $this->assertSame($container, $context->getContainer()); $this->assertSame($arrayCache, $context->getCache()); $this->assertSame($validator, $context->getInputTypeValidator()); - $this->assertSame(self::GLOB_TTL_SECONDS, $context->getGlobTTL()); - $this->assertNull($context->getMapTTL()); + $this->assertSame($classFinder, $context->getClassFinder()); + $this->assertSame($classFinderComputedCache, $context->getClassFinderComputedCache()); + $this->assertSame($classBoundCache, $context->getClassBoundCache()); } } diff --git a/tests/FieldsBuilderTest.php b/tests/FieldsBuilderTest.php index ffcc0b522f..0cd11d0d49 100644 --- a/tests/FieldsBuilderTest.php +++ b/tests/FieldsBuilderTest.php @@ -67,7 +67,7 @@ use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewarePipe; use TheCodingMachine\GraphQLite\Middlewares\MissingMagicGetException; use TheCodingMachine\GraphQLite\Parameters\MissingArgumentException; -use TheCodingMachine\GraphQLite\Reflection\CachedDocBlockFactory; +use TheCodingMachine\GraphQLite\Reflection\DocBlock\CachedDocBlockFactory; use TheCodingMachine\GraphQLite\Security\AuthenticationServiceInterface; use TheCodingMachine\GraphQLite\Security\AuthorizationServiceInterface; use TheCodingMachine\GraphQLite\Security\VoidAuthenticationService; @@ -353,7 +353,7 @@ public function getUser(): object|null $this->getTypeMapper(), $this->getArgumentResolver(), $this->getTypeResolver(), - new CachedDocBlockFactory(new Psr16Cache(new ArrayAdapter())), + $this->getDocBlockFactory(), new NamingStrategy(), $this->getRootTypeMapper(), $this->getParameterMiddlewarePipe(), @@ -384,7 +384,7 @@ public function isAllowed(string $right, $subject = null): bool $this->getTypeMapper(), $this->getArgumentResolver(), $this->getTypeResolver(), - new CachedDocBlockFactory(new Psr16Cache(new ArrayAdapter())), + $this->getDocBlockFactory(), new NamingStrategy(), $this->getRootTypeMapper(), $this->getParameterMiddlewarePipe(), @@ -445,7 +445,7 @@ public function testFromSourceFieldsInterface(): void $this->getTypeMapper(), $this->getArgumentResolver(), $this->getTypeResolver(), - new CachedDocBlockFactory(new Psr16Cache(new ArrayAdapter())), + $this->getDocBlockFactory(), new NamingStrategy(), $this->getRootTypeMapper(), $this->getParameterMiddlewarePipe(), diff --git a/tests/GlobControllerQueryProviderTest.php b/tests/GlobControllerQueryProviderTest.php index 25f824b2f5..da72610c7b 100644 --- a/tests/GlobControllerQueryProviderTest.php +++ b/tests/GlobControllerQueryProviderTest.php @@ -9,6 +9,8 @@ use ReflectionClass; use Symfony\Component\Cache\Adapter\NullAdapter; use Symfony\Component\Cache\Psr16Cache; +use TheCodingMachine\GraphQLite\Discovery\Cache\HardClassFinderComputedCache; +use TheCodingMachine\GraphQLite\Discovery\KcsClassFinder; use TheCodingMachine\GraphQLite\Fixtures\TestController; class GlobControllerQueryProviderTest extends AbstractQueryProvider @@ -38,15 +40,14 @@ public function has($id): bool }; $finder = new ComposerFinder(); + $finder->inNamespace('TheCodingMachine\\GraphQLite\\Fixtures'); $finder->filter(static fn (ReflectionClass $class) => $class->getNamespaceName() === 'TheCodingMachine\\GraphQLite\\Fixtures'); // Fix for recursive:false $globControllerQueryProvider = new GlobControllerQueryProvider( - 'TheCodingMachine\\GraphQLite\\Fixtures', $this->getFieldsBuilder(), $container, $this->getAnnotationReader(), - new Psr16Cache(new NullAdapter()), - $finder, - 0, + new KcsClassFinder($finder), + new HardClassFinderComputedCache(new Psr16Cache(new NullAdapter())) ); $queries = $globControllerQueryProvider->getQueries(); diff --git a/tests/Http/Psr15GraphQLMiddlewareBuilderTest.php b/tests/Http/Psr15GraphQLMiddlewareBuilderTest.php index 885b64f891..d72062ac30 100644 --- a/tests/Http/Psr15GraphQLMiddlewareBuilderTest.php +++ b/tests/Http/Psr15GraphQLMiddlewareBuilderTest.php @@ -39,9 +39,7 @@ public function testCreateMiddleware() $factory = new SchemaFactory($cache, $container); $factory->setAuthenticationService(new VoidAuthenticationService()); $factory->setAuthorizationService(new VoidAuthorizationService()); - - $factory->addControllerNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers'); - $factory->addTypeNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); + $factory->addNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); $schema = $factory->createSchema(); diff --git a/tests/Integration/AnnotatedInterfaceTest.php b/tests/Integration/AnnotatedInterfaceTest.php index d3c81b64ce..b24666de2b 100644 --- a/tests/Integration/AnnotatedInterfaceTest.php +++ b/tests/Integration/AnnotatedInterfaceTest.php @@ -24,8 +24,7 @@ public function setUp(): void $container = new BasicAutoWiringContainer(new EmptyContainer()); $schemaFactory = new SchemaFactory(new Psr16Cache(new ArrayAdapter()), $container); - $schemaFactory->addControllerNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\AnnotatedInterfaces\\Controllers'); - $schemaFactory->addTypeNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\AnnotatedInterfaces\\Types'); + $schemaFactory->addNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\AnnotatedInterfaces'); $this->schema = $schemaFactory->createSchema(); } diff --git a/tests/Integration/EndToEndTest.php b/tests/Integration/EndToEndTest.php index ca9eb58033..0d9f2086c4 100644 --- a/tests/Integration/EndToEndTest.php +++ b/tests/Integration/EndToEndTest.php @@ -5,23 +5,16 @@ namespace TheCodingMachine\GraphQLite\Integration; use GraphQL\Error\DebugFlag; -use GraphQL\Executor\ExecutionResult; use GraphQL\GraphQL; use GraphQL\Server\Helper; use GraphQL\Server\OperationParams; use GraphQL\Server\ServerConfig; -use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; use stdClass; use Symfony\Component\Cache\Adapter\ArrayAdapter; -use Symfony\Component\Cache\Adapter\Psr16Adapter; use Symfony\Component\Cache\Psr16Cache; -use Symfony\Component\ExpressionLanguage\ExpressionLanguage; -use TheCodingMachine\GraphQLite\AggregateQueryProvider; -use TheCodingMachine\GraphQLite\AnnotationReader; use TheCodingMachine\GraphQLite\Containers\BasicAutoWiringContainer; use TheCodingMachine\GraphQLite\Containers\EmptyContainer; -use TheCodingMachine\GraphQLite\Containers\LazyContainer; use TheCodingMachine\GraphQLite\Context\Context; use TheCodingMachine\GraphQLite\Exceptions\WebonyxErrorHandler; use TheCodingMachine\GraphQLite\FieldsBuilder; @@ -30,73 +23,25 @@ use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Color; use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Position; use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Size; -use TheCodingMachine\GraphQLite\GlobControllerQueryProvider; use TheCodingMachine\GraphQLite\GraphQLRuntimeException; use TheCodingMachine\GraphQLite\InputTypeGenerator; use TheCodingMachine\GraphQLite\InputTypeUtils; use TheCodingMachine\GraphQLite\Loggers\ExceptionLogger; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException; -use TheCodingMachine\GraphQLite\Mappers\CompositeTypeMapper; -use TheCodingMachine\GraphQLite\Mappers\GlobTypeMapper; -use TheCodingMachine\GraphQLite\Mappers\Parameters\ContainerParameterHandler; -use TheCodingMachine\GraphQLite\Mappers\Parameters\InjectUserParameterHandler; -use TheCodingMachine\GraphQLite\Mappers\Parameters\ParameterMiddlewareInterface; -use TheCodingMachine\GraphQLite\Mappers\Parameters\ParameterMiddlewarePipe; -use TheCodingMachine\GraphQLite\Mappers\Parameters\PrefetchParameterMiddleware; -use TheCodingMachine\GraphQLite\Mappers\Parameters\ResolveInfoParameterHandler; -use TheCodingMachine\GraphQLite\Mappers\PorpaginasTypeMapper; -use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapper; -use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapperInterface; -use TheCodingMachine\GraphQLite\Mappers\Root\BaseTypeMapper; -use TheCodingMachine\GraphQLite\Mappers\Root\CompoundTypeMapper; -use TheCodingMachine\GraphQLite\Mappers\Root\EnumTypeMapper; -use TheCodingMachine\GraphQLite\Mappers\Root\FinalRootTypeMapper; -use TheCodingMachine\GraphQLite\Mappers\Root\IteratorTypeMapper; -use TheCodingMachine\GraphQLite\Mappers\Root\LastDelegatingTypeMapper; -use TheCodingMachine\GraphQLite\Mappers\Root\MyCLabsEnumTypeMapper; -use TheCodingMachine\GraphQLite\Mappers\Root\NullableTypeMapperAdapter; -use TheCodingMachine\GraphQLite\Mappers\Root\RootTypeMapperInterface; -use TheCodingMachine\GraphQLite\Mappers\Root\VoidTypeMapper; -use TheCodingMachine\GraphQLite\Mappers\TypeMapperInterface; -use TheCodingMachine\GraphQLite\Middlewares\AuthorizationFieldMiddleware; -use TheCodingMachine\GraphQLite\Middlewares\AuthorizationInputFieldMiddleware; -use TheCodingMachine\GraphQLite\Middlewares\CostFieldMiddleware; -use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewareInterface; -use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewarePipe; -use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewareInterface; -use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewarePipe; use TheCodingMachine\GraphQLite\Middlewares\MissingAuthorizationException; use TheCodingMachine\GraphQLite\Middlewares\PrefetchFieldMiddleware; -use TheCodingMachine\GraphQLite\Middlewares\SecurityFieldMiddleware; -use TheCodingMachine\GraphQLite\Middlewares\SecurityInputFieldMiddleware; -use TheCodingMachine\GraphQLite\NamingStrategy; -use TheCodingMachine\GraphQLite\NamingStrategyInterface; -use TheCodingMachine\GraphQLite\ParameterizedCallableResolver; -use TheCodingMachine\GraphQLite\QueryProviderInterface; -use TheCodingMachine\GraphQLite\Reflection\CachedDocBlockFactory; use TheCodingMachine\GraphQLite\Schema; use TheCodingMachine\GraphQLite\SchemaFactory; use TheCodingMachine\GraphQLite\Security\AuthenticationServiceInterface; use TheCodingMachine\GraphQLite\Security\AuthorizationServiceInterface; -use TheCodingMachine\GraphQLite\Security\SecurityExpressionLanguageProvider; use TheCodingMachine\GraphQLite\Security\VoidAuthenticationService; -use TheCodingMachine\GraphQLite\Security\VoidAuthorizationService; -use TheCodingMachine\GraphQLite\TypeGenerator; use TheCodingMachine\GraphQLite\TypeMismatchRuntimeException; -use TheCodingMachine\GraphQLite\TypeRegistry; -use TheCodingMachine\GraphQLite\Types\ArgumentResolver; -use TheCodingMachine\GraphQLite\Types\TypeResolver; use TheCodingMachine\GraphQLite\Utils\AccessPropertyException; -use TheCodingMachine\GraphQLite\Utils\Namespaces\NamespaceFactory; -use UnitEnum; - use function array_filter; use function assert; use function count; use function in_array; -use function interface_exists; use function json_encode; - use const JSON_PRETTY_PRINT; class EndToEndTest extends IntegrationTestCase @@ -687,7 +632,7 @@ public function testEndToEndStaticFactories(): void 'echoFilters' => ['foo', 'bar', '12', '42', '62'], ], $this->getSuccessResult($result)); - // Call again to test GlobTypeMapper cache + // Call again to test ClassFinderTypeMapper cache $result = GraphQL::executeQuery( $schema, $queryString, @@ -1548,8 +1493,7 @@ public function testInputOutputNameConflict(): void $arrayAdapter = new ArrayAdapter(); $arrayAdapter->setLogger(new ExceptionLogger()); $schemaFactory = new SchemaFactory(new Psr16Cache($arrayAdapter), new BasicAutoWiringContainer(new EmptyContainer())); - $schemaFactory->addControllerNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\InputOutputNameConflict\\Controllers'); - $schemaFactory->addTypeNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\InputOutputNameConflict\\Types'); + $schemaFactory->addNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\InputOutputNameConflict'); $schema = $schemaFactory->createSchema(); @@ -1872,9 +1816,7 @@ public function testEndToEndInputTypeValidation(): void $arrayAdapter = new ArrayAdapter(); $arrayAdapter->setLogger(new ExceptionLogger()); $schemaFactory = new SchemaFactory(new Psr16Cache($arrayAdapter), new BasicAutoWiringContainer(new EmptyContainer())); - $schemaFactory->addControllerNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers'); - $schemaFactory->addTypeNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models'); - $schemaFactory->addTypeNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Types'); + $schemaFactory->addNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); $schemaFactory->setAuthenticationService($container->get(AuthenticationServiceInterface::class)); $schemaFactory->setAuthorizationService($container->get(AuthorizationServiceInterface::class)); $schemaFactory->setInputTypeValidator($validator); @@ -2250,8 +2192,7 @@ public function testCircularInput(): void $arrayAdapter = new ArrayAdapter(); $arrayAdapter->setLogger(new ExceptionLogger()); $schemaFactory = new SchemaFactory(new Psr16Cache($arrayAdapter), new BasicAutoWiringContainer(new EmptyContainer())); - $schemaFactory->addControllerNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\CircularInputReference\\Controllers'); - $schemaFactory->addTypeNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\CircularInputReference\\Types'); + $schemaFactory->addNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\CircularInputReference'); $schema = $schemaFactory->createSchema(); diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php index 06db73cce9..200247f56e 100644 --- a/tests/Integration/IntegrationTestCase.php +++ b/tests/Integration/IntegrationTestCase.php @@ -5,7 +5,6 @@ use GraphQL\Error\DebugFlag; use GraphQL\Executor\ExecutionResult; use Kcs\ClassFinder\Finder\ComposerFinder; -use Kcs\ClassFinder\Finder\FinderInterface; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; use stdClass; @@ -15,16 +14,23 @@ use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use TheCodingMachine\GraphQLite\AggregateQueryProvider; use TheCodingMachine\GraphQLite\AnnotationReader; +use TheCodingMachine\GraphQLite\Cache\ClassBoundCache; +use TheCodingMachine\GraphQLite\Cache\FilesSnapshot; +use TheCodingMachine\GraphQLite\Cache\SnapshotClassBoundCache; use TheCodingMachine\GraphQLite\Containers\BasicAutoWiringContainer; use TheCodingMachine\GraphQLite\Containers\EmptyContainer; use TheCodingMachine\GraphQLite\Containers\LazyContainer; +use TheCodingMachine\GraphQLite\Discovery\Cache\ClassFinderComputedCache; +use TheCodingMachine\GraphQLite\Discovery\Cache\HardClassFinderComputedCache; +use TheCodingMachine\GraphQLite\Discovery\ClassFinder; +use TheCodingMachine\GraphQLite\Discovery\KcsClassFinder; use TheCodingMachine\GraphQLite\FieldsBuilder; use TheCodingMachine\GraphQLite\GlobControllerQueryProvider; use TheCodingMachine\GraphQLite\InputTypeGenerator; use TheCodingMachine\GraphQLite\InputTypeUtils; use TheCodingMachine\GraphQLite\Loggers\ExceptionLogger; +use TheCodingMachine\GraphQLite\Mappers\ClassFinderTypeMapper; use TheCodingMachine\GraphQLite\Mappers\CompositeTypeMapper; -use TheCodingMachine\GraphQLite\Mappers\GlobTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Parameters\ContainerParameterHandler; use TheCodingMachine\GraphQLite\Mappers\Parameters\InjectUserParameterHandler; use TheCodingMachine\GraphQLite\Mappers\Parameters\ParameterMiddlewareInterface; @@ -58,7 +64,9 @@ use TheCodingMachine\GraphQLite\NamingStrategyInterface; use TheCodingMachine\GraphQLite\ParameterizedCallableResolver; use TheCodingMachine\GraphQLite\QueryProviderInterface; -use TheCodingMachine\GraphQLite\Reflection\CachedDocBlockFactory; +use TheCodingMachine\GraphQLite\Reflection\DocBlock\CachedDocBlockFactory; +use TheCodingMachine\GraphQLite\Reflection\DocBlock\DocBlockFactory; +use TheCodingMachine\GraphQLite\Reflection\DocBlock\PhpDocumentorDocBlockFactory; use TheCodingMachine\GraphQLite\Schema; use TheCodingMachine\GraphQLite\Security\AuthenticationServiceInterface; use TheCodingMachine\GraphQLite\Security\AuthorizationServiceInterface; @@ -69,8 +77,6 @@ use TheCodingMachine\GraphQLite\TypeRegistry; use TheCodingMachine\GraphQLite\Types\ArgumentResolver; use TheCodingMachine\GraphQLite\Types\TypeResolver; -use TheCodingMachine\GraphQLite\Utils\Namespaces\NamespaceFactory; -use UnitEnum; class IntegrationTestCase extends TestCase { @@ -88,26 +94,36 @@ public function createContainer(array $overloadedServices = []): ContainerInterf Schema::class => static function (ContainerInterface $container) { return new Schema($container->get(QueryProviderInterface::class), $container->get(RecursiveTypeMapperInterface::class), $container->get(TypeResolver::class), $container->get(RootTypeMapperInterface::class)); }, - FinderInterface::class => fn () => new ComposerFinder(), + ClassFinder::class => function () { + $composerFinder = new ComposerFinder(); + $composerFinder->inNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Types'); + $composerFinder->inNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models'); + $composerFinder->inNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers'); + + return new KcsClassFinder($composerFinder); + }, + ClassFinderComputedCache::class => function () { + return new HardClassFinderComputedCache( + new Psr16Cache(new ArrayAdapter()), + ); + }, QueryProviderInterface::class => static function (ContainerInterface $container) { $queryProvider = new GlobControllerQueryProvider( - 'TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers', $container->get(FieldsBuilder::class), $container->get(BasicAutoWiringContainer::class), $container->get(AnnotationReader::class), - new Psr16Cache(new ArrayAdapter()), - $container->get(FinderInterface::class), + $container->get(ClassFinder::class), + $container->get(ClassFinderComputedCache::class), ); $queryProvider = new AggregateQueryProvider([ $queryProvider, new GlobControllerQueryProvider( - 'TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers', $container->get(FieldsBuilder::class), $container->get(BasicAutoWiringContainer::class), $container->get(AnnotationReader::class), - new Psr16Cache(new ArrayAdapter()), - $container->get(FinderInterface::class), + $container->get(ClassFinder::class), + $container->get(ClassFinderComputedCache::class), ), ]); @@ -120,7 +136,7 @@ public function createContainer(array $overloadedServices = []): ContainerInterf $container->get(RecursiveTypeMapperInterface::class), $container->get(ArgumentResolver::class), $container->get(TypeResolver::class), - $container->get(CachedDocBlockFactory::class), + $container->get(DocBlockFactory::class), $container->get(NamingStrategyInterface::class), $container->get(RootTypeMapperInterface::class), $parameterMiddlewarePipe, @@ -202,19 +218,11 @@ public function createContainer(array $overloadedServices = []): ContainerInterf TypeMapperInterface::class => static function (ContainerInterface $container) { return new CompositeTypeMapper(); }, - NamespaceFactory::class => static function (ContainerInterface $container) { - $arrayAdapter = new ArrayAdapter(); - $arrayAdapter->setLogger(new ExceptionLogger()); - return new NamespaceFactory( - new Psr16Cache($arrayAdapter), - $container->get(FinderInterface::class), - ); - }, - GlobTypeMapper::class => static function (ContainerInterface $container) { + ClassFinderTypeMapper::class => static function (ContainerInterface $container) { $arrayAdapter = new ArrayAdapter(); $arrayAdapter->setLogger(new ExceptionLogger()); - return new GlobTypeMapper( - $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Types'), + return new ClassFinderTypeMapper( + $container->get(ClassFinder::class), $container->get(TypeGenerator::class), $container->get(InputTypeGenerator::class), $container->get(InputTypeUtils::class), @@ -222,23 +230,7 @@ public function createContainer(array $overloadedServices = []): ContainerInterf $container->get(AnnotationReader::class), $container->get(NamingStrategyInterface::class), $container->get(RecursiveTypeMapperInterface::class), - new Psr16Cache($arrayAdapter), - ); - }, - // We use a second type mapper here so we can target the Models dir - GlobTypeMapper::class . '2' => static function (ContainerInterface $container) { - $arrayAdapter = new ArrayAdapter(); - $arrayAdapter->setLogger(new ExceptionLogger()); - return new GlobTypeMapper( - $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models'), - $container->get(TypeGenerator::class), - $container->get(InputTypeGenerator::class), - $container->get(InputTypeUtils::class), - $container->get(BasicAutoWiringContainer::class), - $container->get(AnnotationReader::class), - $container->get(NamingStrategyInterface::class), - $container->get(RecursiveTypeMapperInterface::class), - new Psr16Cache($arrayAdapter), + $container->get(ClassFinderComputedCache::class), ); }, PorpaginasTypeMapper::class => static function (ContainerInterface $container) { @@ -248,11 +240,8 @@ public function createContainer(array $overloadedServices = []): ContainerInterf return new EnumTypeMapper( $container->get(RootTypeMapperInterface::class), $container->get(AnnotationReader::class), - new ArrayAdapter(), - [ - $container->get(NamespaceFactory::class) - ->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models'), - ], + $container->get(ClassFinder::class), + $container->get(ClassFinderComputedCache::class), ); }, TypeGenerator::class => static function (ContainerInterface $container) { @@ -286,10 +275,18 @@ public function createContainer(array $overloadedServices = []): ContainerInterf NamingStrategyInterface::class => static function () { return new NamingStrategy(); }, - CachedDocBlockFactory::class => static function () { + ClassBoundCache::class => static function () { $arrayAdapter = new ArrayAdapter(); $arrayAdapter->setLogger(new ExceptionLogger()); - return new CachedDocBlockFactory(new Psr16Cache($arrayAdapter)); + $psr16Cache = new Psr16Cache($arrayAdapter); + + return new SnapshotClassBoundCache($psr16Cache, FilesSnapshot::alwaysUnchanged(...)); + }, + DocBlockFactory::class => static function (ContainerInterface $container) { + return new CachedDocBlockFactory( + $container->get(ClassBoundCache::class), + PhpDocumentorDocBlockFactory::default(), + ); }, RootTypeMapperInterface::class => static function (ContainerInterface $container) { return new VoidTypeMapper( @@ -305,8 +302,8 @@ public function createContainer(array $overloadedServices = []): ContainerInterf // These are in reverse order of execution $errorRootTypeMapper = new FinalRootTypeMapper($container->get(RecursiveTypeMapperInterface::class)); $rootTypeMapper = new BaseTypeMapper($errorRootTypeMapper, $container->get(RecursiveTypeMapperInterface::class), $container->get(RootTypeMapperInterface::class)); - $rootTypeMapper = new MyCLabsEnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), new ArrayAdapter(), [ $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models') ]); - $rootTypeMapper = new EnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), new ArrayAdapter(), [ $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models') ]); + $rootTypeMapper = new MyCLabsEnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), $container->get(ClassFinder::class), $container->get(ClassFinderComputedCache::class)); + $rootTypeMapper = new EnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), $container->get(DocBlockFactory::class), $container->get(ClassFinder::class), $container->get(ClassFinderComputedCache::class)); $rootTypeMapper = new CompoundTypeMapper($rootTypeMapper, $container->get(RootTypeMapperInterface::class), $container->get(NamingStrategyInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class)); $rootTypeMapper = new IteratorTypeMapper($rootTypeMapper, $container->get(RootTypeMapperInterface::class)); return $rootTypeMapper; @@ -336,8 +333,7 @@ public function createContainer(array $overloadedServices = []): ContainerInterf $container = new LazyContainer($overloadedServices + $services); $container->get(TypeResolver::class)->registerSchema($container->get(Schema::class)); - $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(GlobTypeMapper::class)); - $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(GlobTypeMapper::class . '2')); + $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(ClassFinderTypeMapper::class)); $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(PorpaginasTypeMapper::class)); $container->get('topRootTypeMapper')->setNext($container->get('rootTypeMapper')); diff --git a/tests/Mappers/GlobTypeMapperTest.php b/tests/Mappers/ClassFinderTypeMapperTest.php similarity index 65% rename from tests/Mappers/GlobTypeMapperTest.php rename to tests/Mappers/ClassFinderTypeMapperTest.php index e7e12053ae..1657c271c0 100644 --- a/tests/Mappers/GlobTypeMapperTest.php +++ b/tests/Mappers/ClassFinderTypeMapperTest.php @@ -31,9 +31,9 @@ use TheCodingMachine\GraphQLite\NamingStrategy; use TheCodingMachine\GraphQLite\Types\MutableObjectType; -class GlobTypeMapperTest extends AbstractQueryProvider +class ClassFinderTypeMapperTest extends AbstractQueryProvider { - public function testGlobTypeMapper(): void + public function testClassFinderTypeMapper(): void { $container = new LazyContainer([ FooType::class => static function () { @@ -44,9 +44,9 @@ public function testGlobTypeMapper(): void $typeGenerator = $this->getTypeGenerator(); $inputTypeGenerator = $this->getInputTypeGenerator(); - $cache = new Psr16Cache(new ArrayAdapter()); + $classFinderComputedCache = $this->getClassFinderComputedCache(); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); + $mapper = new ClassFinderTypeMapper($this->getClassFinder('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $classFinderComputedCache); $this->assertSame([TestObject::class], $mapper->getSupportedClasses()); $this->assertTrue($mapper->canMapClassToType(TestObject::class)); @@ -56,7 +56,7 @@ public function testGlobTypeMapper(): void $this->assertFalse($mapper->canMapNameToType('NotExists')); // Again to test cache - $anotherMapperSameCache = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); + $anotherMapperSameCache = new ClassFinderTypeMapper($this->getClassFinder('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $classFinderComputedCache); $this->assertTrue($anotherMapperSameCache->canMapClassToType(TestObject::class)); $this->assertTrue($anotherMapperSameCache->canMapNameToType('Foo')); @@ -64,7 +64,7 @@ public function testGlobTypeMapper(): void $mapper->mapClassToType(stdClass::class, null); } - public function testGlobTypeMapperDuplicateTypesException(): void + public function testClassFinderTypeMapperDuplicateTypesException(): void { $container = new LazyContainer([ TestType::class => static function () { @@ -74,13 +74,13 @@ public function testGlobTypeMapperDuplicateTypesException(): void $typeGenerator = $this->getTypeGenerator(); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\DuplicateTypes'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), new Psr16Cache(new NullAdapter())); + $mapper = new ClassFinderTypeMapper($this->getClassFinder('TheCodingMachine\GraphQLite\Fixtures\DuplicateTypes'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $this->getClassFinderComputedCache()); $this->expectException(DuplicateMappingException::class); $mapper->canMapClassToType(TestType::class); } - public function testGlobTypeMapperDuplicateInputsException(): void + public function testClassFinderTypeMapperDuplicateInputsException(): void { $container = new LazyContainer([ TestInput::class => static function () { @@ -90,13 +90,13 @@ public function testGlobTypeMapperDuplicateInputsException(): void $typeGenerator = $this->getTypeGenerator(); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\DuplicateInputs'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), new Psr16Cache(new NullAdapter())); + $mapper = new ClassFinderTypeMapper($this->getClassFinder('TheCodingMachine\GraphQLite\Fixtures\DuplicateInputs'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $this->getClassFinderComputedCache()); $this->expectException(DuplicateMappingException::class); $mapper->canMapClassToInputType(TestInput::class); } - public function testGlobTypeMapperDuplicateInputTypesException(): void + public function testClassFinderTypeMapperDuplicateInputTypesException(): void { $container = new LazyContainer([ /*TestType::class => function() { @@ -106,7 +106,7 @@ public function testGlobTypeMapperDuplicateInputTypesException(): void $typeGenerator = $this->getTypeGenerator(); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\DuplicateInputTypes'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), new Psr16Cache(new NullAdapter())); + $mapper = new ClassFinderTypeMapper($this->getClassFinder('TheCodingMachine\GraphQLite\Fixtures\DuplicateInputTypes'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $this->getClassFinderComputedCache()); $caught = false; try { @@ -125,7 +125,7 @@ public function testGlobTypeMapperDuplicateInputTypesException(): void $this->assertTrue($caught, 'DuplicateMappingException is thrown'); } - public function testGlobTypeMapperInheritedInputTypesException(): void + public function testClassFinderTypeMapperInheritedInputTypesException(): void { $container = new LazyContainer([ ChildTestFactory::class => static function () { @@ -135,7 +135,7 @@ public function testGlobTypeMapperInheritedInputTypesException(): void $typeGenerator = $this->getTypeGenerator(); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\InheritedInputTypes'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), new Psr16Cache(new NullAdapter())); + $mapper = new ClassFinderTypeMapper($this->getClassFinder('TheCodingMachine\GraphQLite\Fixtures\InheritedInputTypes'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $this->getClassFinderComputedCache()); //$this->expectException(DuplicateMappingException::class); //$this->expectExceptionMessage('The class \'TheCodingMachine\GraphQLite\Fixtures\TestObject\' should be mapped to only one GraphQL Input type. Two methods are pointing via the @Factory annotation to this class: \'TheCodingMachine\GraphQLite\Fixtures\DuplicateInputTypes\TestFactory::myFactory\' and \'TheCodingMachine\GraphQLite\Fixtures\DuplicateInputTypes\TestFactory2::myFactory\''); @@ -143,7 +143,7 @@ public function testGlobTypeMapperInheritedInputTypesException(): void $mapper->mapClassToInputType(TestObject::class); } - public function testGlobTypeMapperClassNotFoundException(): void + public function testClassFinderTypeMapperClassNotFoundException(): void { $container = new LazyContainer([ TestType::class => static function () { @@ -153,14 +153,14 @@ public function testGlobTypeMapperClassNotFoundException(): void $typeGenerator = $this->getTypeGenerator(); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\BadClassType'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), new Psr16Cache(new NullAdapter())); + $mapper = new ClassFinderTypeMapper($this->getClassFinder('TheCodingMachine\GraphQLite\Fixtures\BadClassType'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $this->getClassFinderComputedCache()); $this->expectException(ClassNotFoundException::class); $this->expectExceptionMessage("Could not autoload class 'Foobar' defined in #[Type] attribute of class 'TheCodingMachine\\GraphQLite\\Fixtures\\BadClassType\\TestType'"); $mapper->canMapClassToType(TestType::class); } - public function testGlobTypeMapperNameNotFoundException(): void + public function testClassFinderTypeMapperNameNotFoundException(): void { $container = new LazyContainer([ FooType::class => static function () { @@ -170,13 +170,13 @@ public function testGlobTypeMapperNameNotFoundException(): void $typeGenerator = $this->getTypeGenerator(); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), new Psr16Cache(new NullAdapter())); + $mapper = new ClassFinderTypeMapper($this->getClassFinder('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $this->getClassFinderComputedCache()); $this->expectException(CannotMapTypeException::class); $mapper->mapNameToType('NotExists', $this->getTypeMapper()); } - public function testGlobTypeMapperInputType(): void + public function testClassFinderTypeMapperInputType(): void { $container = new LazyContainer([ FooType::class => static function () { @@ -189,9 +189,9 @@ public function testGlobTypeMapperInputType(): void $typeGenerator = $this->getTypeGenerator(); - $cache = new Psr16Cache(new ArrayAdapter()); + $classFinderComputedCache = $this->getClassFinderComputedCache(); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); + $mapper = new ClassFinderTypeMapper($this->getClassFinder('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $classFinderComputedCache); $this->assertTrue($mapper->canMapClassToInputType(TestObject::class)); @@ -200,7 +200,7 @@ public function testGlobTypeMapperInputType(): void $this->assertSame('TestObjectInput', $inputType->name); // Again to test cache - $anotherMapperSameCache = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); + $anotherMapperSameCache = new ClassFinderTypeMapper($this->getClassFinder('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $classFinderComputedCache); $this->assertTrue($anotherMapperSameCache->canMapClassToInputType(TestObject::class)); $this->assertSame('TestObjectInput', $anotherMapperSameCache->mapClassToInputType(TestObject::class, $this->getTypeMapper())->name); @@ -209,7 +209,7 @@ public function testGlobTypeMapperInputType(): void $mapper->mapClassToInputType(TestType::class, $this->getTypeMapper()); } - public function testGlobTypeMapperExtend(): void + public function testClassFinderTypeMapperExtend(): void { $container = new LazyContainer([ FooType::class => static function () { @@ -223,9 +223,9 @@ public function testGlobTypeMapperExtend(): void $typeGenerator = $this->getTypeGenerator(); $inputTypeGenerator = $this->getInputTypeGenerator(); - $cache = new Psr16Cache(new ArrayAdapter()); + $classFinderComputedCache = $this->getClassFinderComputedCache(); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); + $mapper = new ClassFinderTypeMapper($this->getClassFinder('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $classFinderComputedCache); $type = $mapper->mapClassToType(TestObject::class, null); @@ -236,7 +236,7 @@ public function testGlobTypeMapperExtend(): void $this->assertFalse($mapper->canExtendTypeForName('NotExists', $type)); // Again to test cache - $anotherMapperSameCache = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); + $anotherMapperSameCache = new ClassFinderTypeMapper($this->getClassFinder('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $classFinderComputedCache); $this->assertTrue($anotherMapperSameCache->canExtendTypeForClass(TestObject::class, $type)); $this->assertTrue($anotherMapperSameCache->canExtendTypeForName('TestObject', $type)); @@ -244,21 +244,21 @@ public function testGlobTypeMapperExtend(): void $mapper->extendTypeForClass(stdClass::class, $type); } - public function testEmptyGlobTypeMapper(): void + public function testEmptyClassFinderTypeMapper(): void { $container = new LazyContainer([]); $typeGenerator = $this->getTypeGenerator(); $inputTypeGenerator = $this->getInputTypeGenerator(); - $cache = new Psr16Cache(new ArrayAdapter()); + $classFinderComputedCache = $this->getClassFinderComputedCache(); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Integration\Controllers'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); + $mapper = new ClassFinderTypeMapper($this->getClassFinder('TheCodingMachine\GraphQLite\Fixtures\Integration\Controllers'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $classFinderComputedCache); $this->assertSame([], $mapper->getSupportedClasses()); } - public function testGlobTypeMapperDecorate(): void + public function testClassFinderTypeMapperDecorate(): void { $container = new LazyContainer([ FilterDecorator::class => static function () { @@ -269,9 +269,9 @@ public function testGlobTypeMapperDecorate(): void $typeGenerator = $this->getTypeGenerator(); $inputTypeGenerator = $this->getInputTypeGenerator(); - $cache = new Psr16Cache(new ArrayAdapter()); + $classFinderComputedCache = $this->getClassFinderComputedCache(); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Integration\Types'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); + $mapper = new ClassFinderTypeMapper($this->getClassFinder('TheCodingMachine\GraphQLite\Fixtures\Integration\Types'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $classFinderComputedCache); $inputType = new MockResolvableInputObjectType(['name' => 'FilterInput']); @@ -294,14 +294,14 @@ public function testInvalidName(): void $typeGenerator = $this->getTypeGenerator(); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), new Psr16Cache(new ArrayAdapter())); + $mapper = new ClassFinderTypeMapper($this->getClassFinder('TheCodingMachine\GraphQLite\Fixtures\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $this->getClassFinderComputedCache()); $this->assertFalse($mapper->canExtendTypeForName('{}()/\\@:', new MutableObjectType(['name' => 'foo']))); $this->assertFalse($mapper->canDecorateInputTypeForName('{}()/\\@:', new MockResolvableInputObjectType(['name' => 'foo']))); $this->assertFalse($mapper->canMapNameToType('{}()/\\@:')); } - public function testGlobTypeMapperExtendBadName(): void + public function testClassFinderTypeMapperExtendBadName(): void { $container = new LazyContainer([ FooType::class => static function () { @@ -318,9 +318,9 @@ public function testGlobTypeMapperExtendBadName(): void $typeGenerator = $this->getTypeGenerator(); $inputTypeGenerator = $this->getInputTypeGenerator(); - $cache = new Psr16Cache(new ArrayAdapter()); + $classFinderComputedCache = $this->getClassFinderComputedCache(); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\BadExtendType'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); + $mapper = new ClassFinderTypeMapper($this->getClassFinder('TheCodingMachine\GraphQLite\Fixtures\BadExtendType'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $classFinderComputedCache); $testObjectType = new MutableObjectType([ 'name' => 'TestObject', @@ -334,7 +334,7 @@ public function testGlobTypeMapperExtendBadName(): void $mapper->extendTypeForName('TestObject', $testObjectType); } - public function testGlobTypeMapperExtendBadClass(): void + public function testClassFinderTypeMapperExtendBadClass(): void { $container = new LazyContainer([ FooType::class => static function () { @@ -351,9 +351,9 @@ public function testGlobTypeMapperExtendBadClass(): void $typeGenerator = $this->getTypeGenerator(); $inputTypeGenerator = $this->getInputTypeGenerator(); - $cache = new Psr16Cache(new ArrayAdapter()); + $classFinderComputedCache = $this->getClassFinderComputedCache(); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\BadExtendType2'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); + $mapper = new ClassFinderTypeMapper($this->getClassFinder('TheCodingMachine\GraphQLite\Fixtures\BadExtendType2'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $classFinderComputedCache); $testObjectType = new MutableObjectType([ 'name' => 'TestObject', @@ -378,9 +378,9 @@ public function testNonInstantiableType(): void $typeGenerator = $this->getTypeGenerator(); $inputTypeGenerator = $this->getInputTypeGenerator(); - $cache = new Psr16Cache(new ArrayAdapter()); + $classFinderComputedCache = $this->getClassFinderComputedCache(); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\NonInstantiableType'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); + $mapper = new ClassFinderTypeMapper($this->getClassFinder('TheCodingMachine\GraphQLite\Fixtures\NonInstantiableType'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $classFinderComputedCache); $this->expectException(GraphQLRuntimeException::class); $this->expectExceptionMessage('Class "TheCodingMachine\GraphQLite\Fixtures\NonInstantiableType\AbstractFooType" annotated with @Type(class="TheCodingMachine\GraphQLite\Fixtures\TestObject") must be instantiable.'); @@ -394,8 +394,8 @@ public function testNonInstantiableInput(): void $typeGenerator = $this->getTypeGenerator(); $inputTypeGenerator = $this->getInputTypeGenerator(); - $cache = new Psr16Cache(new ArrayAdapter()); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\NonInstantiableInput'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); + $classFinderComputedCache = $this->getClassFinderComputedCache(); + $mapper = new ClassFinderTypeMapper($this->getClassFinder('TheCodingMachine\GraphQLite\Fixtures\NonInstantiableInput'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $classFinderComputedCache); $this->expectException(FailedResolvingInputType::class); $this->expectExceptionMessage("Class 'TheCodingMachine\GraphQLite\Fixtures\NonInstantiableInput\AbstractFoo' annotated with @Input must be instantiable."); diff --git a/tests/Mappers/Parameters/TypeMapperTest.php b/tests/Mappers/Parameters/TypeMapperTest.php index 825cdaacb0..b0c6c09ea3 100644 --- a/tests/Mappers/Parameters/TypeMapperTest.php +++ b/tests/Mappers/Parameters/TypeMapperTest.php @@ -16,7 +16,7 @@ use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException; use TheCodingMachine\GraphQLite\Parameters\DefaultValueParameter; use TheCodingMachine\GraphQLite\Parameters\InputTypeParameter; -use TheCodingMachine\GraphQLite\Reflection\CachedDocBlockFactory; +use TheCodingMachine\GraphQLite\Reflection\DocBlock\CachedDocBlockFactory; use function assert; use function count; @@ -25,17 +25,17 @@ class TypeMapperTest extends AbstractQueryProvider { public function testMapScalarUnionException(): void { + $docBlockFactory = $this->getDocBlockFactory(); + $typeMapper = new TypeHandler( $this->getArgumentResolver(), $this->getRootTypeMapper(), $this->getTypeResolver(), - $this->getCachedDocBlockFactory(), + $docBlockFactory, ); - $cachedDocBlockFactory = new CachedDocBlockFactory(new Psr16Cache(new ArrayAdapter())); - $refMethod = new ReflectionMethod($this, 'dummy'); - $docBlockObj = $cachedDocBlockFactory->getDocBlock($refMethod); + $docBlockObj = $docBlockFactory->create($refMethod); $this->expectException(CannotMapTypeException::class); $this->expectExceptionMessage('For return type of TheCodingMachine\GraphQLite\Mappers\Parameters\TypeMapperTest::dummy, in GraphQL, you can only use union types between objects. These types cannot be used in union types: String!, Int!'); @@ -44,17 +44,17 @@ public function testMapScalarUnionException(): void public function testMapObjectUnionWorks(): void { - $cachedDocBlockFactory = $this->getCachedDocBlockFactory(); + $docBlockFactory = $this->getDocBlockFactory(); $typeMapper = new TypeHandler( $this->getArgumentResolver(), $this->getRootTypeMapper(), $this->getTypeResolver(), - $cachedDocBlockFactory, + $docBlockFactory, ); $refMethod = new ReflectionMethod(UnionOutputType::class, 'objectUnion'); - $docBlockObj = $cachedDocBlockFactory->getDocBlock($refMethod); + $docBlockObj = $docBlockFactory->create($refMethod); $gqType = $typeMapper->mapReturnType($refMethod, $docBlockObj); $this->assertInstanceOf(NonNull::class, $gqType); @@ -69,17 +69,17 @@ public function testMapObjectUnionWorks(): void public function testMapObjectNullableUnionWorks(): void { - $cachedDocBlockFactory = $this->getCachedDocBlockFactory(); + $docBlockFactory = $this->getDocBlockFactory(); $typeMapper = new TypeHandler( $this->getArgumentResolver(), $this->getRootTypeMapper(), $this->getTypeResolver(), - $cachedDocBlockFactory, + $docBlockFactory, ); $refMethod = new ReflectionMethod(UnionOutputType::class, 'nullableObjectUnion'); - $docBlockObj = $cachedDocBlockFactory->getDocBlock($refMethod); + $docBlockObj = $docBlockFactory->create($refMethod); $gqType = $typeMapper->mapReturnType($refMethod, $docBlockObj); $this->assertNotInstanceOf(NonNull::class, $gqType); @@ -94,18 +94,18 @@ public function testMapObjectNullableUnionWorks(): void public function testHideParameter(): void { - $cachedDocBlockFactory = $this->getCachedDocBlockFactory(); + $docBlockFactory = $this->getDocBlockFactory(); $typeMapper = new TypeHandler( $this->getArgumentResolver(), $this->getRootTypeMapper(), $this->getTypeResolver(), - $cachedDocBlockFactory, + $docBlockFactory, ); $refMethod = new ReflectionMethod($this, 'withDefaultValue'); $refParameter = $refMethod->getParameters()[0]; - $docBlockObj = $cachedDocBlockFactory->getDocBlock($refMethod); + $docBlockObj = $docBlockFactory->create($refMethod); $annotations = $this->getAnnotationReader()->getParameterAnnotationsPerParameter([$refParameter])['foo']; $param = $typeMapper->mapParameter($refParameter, $docBlockObj, null, $annotations); @@ -118,17 +118,17 @@ public function testHideParameter(): void public function testParameterWithDescription(): void { - $cachedDocBlockFactory = $this->getCachedDocBlockFactory(); + $docBlockFactory = $this->getDocBlockFactory(); $typeMapper = new TypeHandler( $this->getArgumentResolver(), $this->getRootTypeMapper(), $this->getTypeResolver(), - $cachedDocBlockFactory, + $docBlockFactory, ); $refMethod = new ReflectionMethod($this, 'withParamDescription'); - $docBlockObj = $cachedDocBlockFactory->getDocBlock($refMethod); + $docBlockObj = $docBlockFactory->create($refMethod); $refParameter = $refMethod->getParameters()[0]; $parameter = $typeMapper->mapParameter($refParameter, $docBlockObj, null, $this->getAnnotationReader()->getParameterAnnotationsPerParameter([$refParameter])['foo']); @@ -139,18 +139,18 @@ public function testParameterWithDescription(): void public function testHideParameterException(): void { - $cachedDocBlockFactory = $this->getCachedDocBlockFactory(); + $docBlockFactory = $this->getDocBlockFactory(); $typeMapper = new TypeHandler( $this->getArgumentResolver(), $this->getRootTypeMapper(), $this->getTypeResolver(), - $cachedDocBlockFactory, + $docBlockFactory, ); $refMethod = new ReflectionMethod($this, 'withoutDefaultValue'); $refParameter = $refMethod->getParameters()[0]; - $docBlockObj = $cachedDocBlockFactory->getDocBlock($refMethod); + $docBlockObj = $docBlockFactory->create($refMethod); $annotations = $this->getAnnotationReader()->getParameterAnnotationsPerParameter([$refParameter])['foo']; $this->expectException(CannotHideParameterRuntimeException::class); diff --git a/tests/Mappers/RecursiveTypeMapperTest.php b/tests/Mappers/RecursiveTypeMapperTest.php index 8fc14ee27c..a99b70fce9 100644 --- a/tests/Mappers/RecursiveTypeMapperTest.php +++ b/tests/Mappers/RecursiveTypeMapperTest.php @@ -133,7 +133,7 @@ protected function getTypeMapper(): RecursiveTypeMapper $typeGenerator = new TypeGenerator($this->getAnnotationReader(), $namingStrategy, $this->getTypeRegistry(), $this->getRegistry(), $this->typeMapper, $this->getFieldsBuilder()); - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Interfaces\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(), $namingStrategy, $this->typeMapper, new Psr16Cache(new NullAdapter())); + $mapper = new ClassFinderTypeMapper($this->getClassFinder('TheCodingMachine\GraphQLite\Fixtures\Interfaces\Types'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(), $namingStrategy, $this->typeMapper, $this->getClassFinderComputedCache()); $compositeMapper->addTypeMapper($mapper); } return $this->typeMapper; @@ -230,9 +230,7 @@ public function testMapNameToTypeDecorators(): void $typeGenerator = $this->getTypeGenerator(); $inputTypeGenerator = $this->getInputTypeGenerator(); - $cache = new Psr16Cache(new ArrayAdapter()); - - $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\Integration'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $this->getRegistry(), new \TheCodingMachine\GraphQLite\AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); + $mapper = new ClassFinderTypeMapper($this->getClassFinder('TheCodingMachine\GraphQLite\Fixtures\Integration'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $this->getRegistry(), new \TheCodingMachine\GraphQLite\AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $this->getClassFinderComputedCache()); $recursiveTypeMapper = new RecursiveTypeMapper( $mapper, diff --git a/tests/Mappers/Root/MyCLabsEnumTypeMapperTest.php b/tests/Mappers/Root/MyCLabsEnumTypeMapperTest.php index ecd711c945..8663d4f8a9 100644 --- a/tests/Mappers/Root/MyCLabsEnumTypeMapperTest.php +++ b/tests/Mappers/Root/MyCLabsEnumTypeMapperTest.php @@ -15,7 +15,7 @@ class MyCLabsEnumTypeMapperTest extends AbstractQueryProvider { public function testObjectTypeHint(): void { - $mapper = new MyCLabsEnumTypeMapper(new FinalRootTypeMapper($this->getTypeMapper()), $this->getAnnotationReader(), new ArrayAdapter(), []); + $mapper = new MyCLabsEnumTypeMapper(new FinalRootTypeMapper($this->getTypeMapper()), $this->getAnnotationReader(), $this->getClassFinder([]), $this->getClassFinderComputedCache()); $this->expectException(CannotMapTypeException::class); $this->expectExceptionMessage("don't know how to handle type object"); diff --git a/tests/Mappers/StaticClassListTypeMapperTest.php b/tests/Mappers/StaticClassListTypeMapperTest.php deleted file mode 100644 index c77beb3929..0000000000 --- a/tests/Mappers/StaticClassListTypeMapperTest.php +++ /dev/null @@ -1,31 +0,0 @@ -getTypeGenerator(); - $inputTypeGenerator = $this->getInputTypeGenerator(); - - $cache = new Psr16Cache(new ArrayAdapter()); - - $mapper = new StaticClassListTypeMapper(['NotExistsClass'], $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $cache); - - $this->expectException(GraphQLRuntimeException::class); - $this->expectExceptionMessage('Could not find class "NotExistsClass"'); - - $mapper->getSupportedClasses(); - } -} diff --git a/tests/Mappers/StaticTypeMapperTest.php b/tests/Mappers/StaticTypeMapperTest.php index 7918181a2b..bbecb195bb 100644 --- a/tests/Mappers/StaticTypeMapperTest.php +++ b/tests/Mappers/StaticTypeMapperTest.php @@ -137,7 +137,7 @@ public function testEndToEnd(): void $arrayAdapter = new ArrayAdapter(); $arrayAdapter->setLogger(new ExceptionLogger()); $schemaFactory = new SchemaFactory(new Psr16Cache($arrayAdapter), new BasicAutoWiringContainer(new EmptyContainer())); - $schemaFactory->addControllerNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\StaticTypeMapper\\Controllers'); + $schemaFactory->addNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\StaticTypeMapper\\Controllers'); // Let's register a type that maps by default to the "MyClass" PHP class $staticTypeMapper = new StaticTypeMapper( diff --git a/tests/Reflection/CachedDocBlockFactoryTest.php b/tests/Reflection/CachedDocBlockFactoryTest.php deleted file mode 100644 index 1f3a5fbed8..0000000000 --- a/tests/Reflection/CachedDocBlockFactoryTest.php +++ /dev/null @@ -1,48 +0,0 @@ -getDocBlock($refMethod); - $this->assertSame('Fetches a DocBlock object from a ReflectionMethod', $docBlock->getSummary()); - $docBlock2 = $cachedDocBlockFactory->getDocBlock($refMethod); - $this->assertSame($docBlock2, $docBlock); - - $newCachedDocBlockFactory = new CachedDocBlockFactory($arrayCache); - $docBlock3 = $newCachedDocBlockFactory->getDocBlock($refMethod); - $this->assertEquals($docBlock3, $docBlock); - } - - public function testGetContext(): void - { - $arrayCache = new Psr16Cache(new ArrayAdapter()); - $cachedDocBlockFactory = new CachedDocBlockFactory($arrayCache); - - $refClass = new ReflectionClass(CachedDocBlockFactory::class); - - $context = $cachedDocBlockFactory->getContextFromClass($refClass); - - $context2 = $cachedDocBlockFactory->getContextFromClass($refClass); - $this->assertSame($context2, $context); - - $newCachedDocBlockFactory = new CachedDocBlockFactory($arrayCache); - $context3 = $newCachedDocBlockFactory->getContextFromClass($refClass); - $this->assertEquals($context3, $context); - } -} diff --git a/tests/Reflection/DocBlock/CachedDocBlockFactoryTest.php b/tests/Reflection/DocBlock/CachedDocBlockFactoryTest.php new file mode 100644 index 0000000000..f1b13ae077 --- /dev/null +++ b/tests/Reflection/DocBlock/CachedDocBlockFactoryTest.php @@ -0,0 +1,62 @@ +create($refMethod); + $this->assertSame('Fetches a DocBlock object from a ReflectionMethod', $docBlock->getSummary()); + $docBlock2 = $cachedDocBlockFactory->create($refMethod); + $this->assertSame($docBlock2, $docBlock); + + $newCachedDocBlockFactory = new CachedDocBlockFactory( + new SnapshotClassBoundCache($arrayCache, FilesSnapshot::alwaysUnchanged(...)), + PhpDocumentorDocBlockFactory::default(), + ); + $docBlock3 = $newCachedDocBlockFactory->create($refMethod); + $this->assertEquals($docBlock3, $docBlock); + } + + public function testCreatesContext(): void + { + $arrayCache = new Psr16Cache(new ArrayAdapter(storeSerialized: false)); + $cachedDocBlockFactory = new CachedDocBlockFactory( + new SnapshotClassBoundCache($arrayCache, FilesSnapshot::alwaysUnchanged(...)), + PhpDocumentorDocBlockFactory::default(), + ); + + $refMethod = new ReflectionMethod(DocBlockFactory::class, 'create'); + + $docBlock = $cachedDocBlockFactory->createContext($refMethod); + $this->assertSame('TheCodingMachine\GraphQLite\Reflection\DocBlock', $docBlock->getNamespace()); + $docBlock2 = $cachedDocBlockFactory->createContext($refMethod); + $this->assertSame($docBlock2, $docBlock); + + $newCachedDocBlockFactory = new CachedDocBlockFactory( + new SnapshotClassBoundCache($arrayCache, FilesSnapshot::alwaysUnchanged(...)), + PhpDocumentorDocBlockFactory::default(), + ); + $docBlock3 = $newCachedDocBlockFactory->createContext($refMethod); + $this->assertEquals($docBlock3, $docBlock); + } +} diff --git a/tests/Reflection/DocBlock/PhpDocumentorDocBlockFactoryTest.php b/tests/Reflection/DocBlock/PhpDocumentorDocBlockFactoryTest.php new file mode 100644 index 0000000000..d8ad70f36b --- /dev/null +++ b/tests/Reflection/DocBlock/PhpDocumentorDocBlockFactoryTest.php @@ -0,0 +1,47 @@ +create($refMethod); + + $this->assertCount(1, $docBlock->getTagsByName('param')); + + /** @var Param $paramTag */ + $paramTag = $docBlock->getTagsByName('param')[0]; + + $this->assertEquals( + new Array_( + new Object_(new Fqsen('\\' . TestObject::class)) + ), + $paramTag->getType(), + ); + } + + public function testCreatesContext(): void + { + $docBlockFactory = PhpDocumentorDocBlockFactory::default(); + + $refMethod = (new ReflectionMethod(TestController::class, 'test')); + $context = $docBlockFactory->createContext($refMethod); + + $this->assertSame('TheCodingMachine\GraphQLite\Fixtures', $context->getNamespace()); + } +} diff --git a/tests/RootTypeMapperFactoryContextTest.php b/tests/RootTypeMapperFactoryContextTest.php index a87f5b7b9a..97d56d1d3c 100644 --- a/tests/RootTypeMapperFactoryContextTest.php +++ b/tests/RootTypeMapperFactoryContextTest.php @@ -5,10 +5,10 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Psr16Cache; -use Symfony\Component\Cache\Simple\ArrayCache; +use TheCodingMachine\GraphQLite\Cache\FilesSnapshot; +use TheCodingMachine\GraphQLite\Cache\SnapshotClassBoundCache; use TheCodingMachine\GraphQLite\Containers\EmptyContainer; use TheCodingMachine\GraphQLite\Mappers\Root\RootTypeMapperFactoryContext; -use TheCodingMachine\GraphQLite\Utils\Namespaces\NS; class RootTypeMapperFactoryContextTest extends AbstractQueryProvider { @@ -19,7 +19,9 @@ public function testContext(): void $namingStrategy = new NamingStrategy(); $container = new EmptyContainer(); $arrayCache = new Psr16Cache(new ArrayAdapter()); - $nsList = [$this->getNamespaceFactory()->createNamespace('namespace')]; + $classFinder = $this->getClassFinder('namespace'); + $classFinderComputedCache = $this->getClassFinderComputedCache(); + $classBoundCache = new SnapshotClassBoundCache($arrayCache, FilesSnapshot::alwaysUnchanged(...)); $context = new RootTypeMapperFactoryContext( $this->getAnnotationReader(), @@ -29,8 +31,9 @@ public function testContext(): void $this->getTypeMapper(), $container, $arrayCache, - $nsList, - self::GLOB_TTL_SECONDS + $classFinder, + $classFinderComputedCache, + $classBoundCache, ); $this->assertSame($this->getAnnotationReader(), $context->getAnnotationReader()); @@ -40,9 +43,8 @@ public function testContext(): void $this->assertSame($this->getTypeMapper(), $context->getRecursiveTypeMapper()); $this->assertSame($container, $context->getContainer()); $this->assertSame($arrayCache, $context->getCache()); - $this->assertSame($nsList, $context->getTypeNamespaces()); - $this->assertContainsOnlyInstancesOf(NS::class, $context->getTypeNamespaces()); - $this->assertSame(self::GLOB_TTL_SECONDS, $context->getGlobTTL()); - $this->assertNull($context->getMapTTL()); + $this->assertSame($classFinder, $context->getClassFinder()); + $this->assertSame($classFinderComputedCache, $context->getClassFinderComputedCache()); + $this->assertSame($classBoundCache, $context->getClassBoundCache()); } } diff --git a/tests/SchemaFactoryTest.php b/tests/SchemaFactoryTest.php index 31666902a7..78defd5b78 100644 --- a/tests/SchemaFactoryTest.php +++ b/tests/SchemaFactoryTest.php @@ -50,8 +50,7 @@ public function testCreateSchema(): void $factory->setAuthenticationService(new VoidAuthenticationService()); $factory->setAuthorizationService(new VoidAuthorizationService()); - $factory->addControllerNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers'); - $factory->addTypeNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); + $factory->addNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); $factory->addQueryProvider(new AggregateQueryProvider([])); $factory->addFieldMiddleware(new FieldMiddlewarePipe()); $factory->addInputFieldMiddleware(new InputFieldMiddlewarePipe()); @@ -68,8 +67,7 @@ public function testSetters(): void $factory = new SchemaFactory($cache, $container); - $factory->addControllerNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers'); - $factory->addTypeNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); + $factory->addNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); $factory->setAuthenticationService(new VoidAuthenticationService()) ->setAuthorizationService(new VoidAuthorizationService()) ->setNamingStrategy(new NamingStrategy()) @@ -99,8 +97,7 @@ public function testFinderInjectionWithValidMapper(): void $factory->setAuthenticationService(new VoidAuthenticationService()) ->setAuthorizationService(new VoidAuthorizationService()) ->setFinder(new ComposerFinder()) - ->addControllerNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers') - ->addTypeNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); + ->addNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); $schema = $factory->createSchema(); @@ -146,8 +143,7 @@ public function testFinderInjectionWithInvalidMapper(): void $factory->setAuthenticationService(new VoidAuthenticationService()) ->setAuthorizationService(new VoidAuthorizationService()) ->setFinder(new RecursiveFinder(__DIR__ . '/Annotations')) - ->addControllerNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers') - ->addTypeNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); + ->addNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); $this->doTestSchemaWithError($factory->createSchema()); } @@ -163,18 +159,6 @@ public function testException(): void $factory->createSchema(); } - public function testException2(): void - { - $container = new BasicAutoWiringContainer(new EmptyContainer()); - $cache = new Psr16Cache(new ArrayAdapter()); - - $factory = new SchemaFactory($cache, $container); - $factory->addTypeNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); - - $this->expectException(GraphQLRuntimeException::class); - $factory->createSchema(); - } - private function execTestQuery(Schema $schema): ExecutionResult { $schema->assertValid(); @@ -238,8 +222,8 @@ public function testDuplicateQueryException(): void ); $factory->setAuthenticationService(new VoidAuthenticationService()) ->setAuthorizationService(new VoidAuthorizationService()) - ->addControllerNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\DuplicateQueries') - ->addTypeNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); + ->addNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\DuplicateQueries') + ->addNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); $this->expectException(DuplicateMappingException::class); $this->expectExceptionMessage("The query/mutation/field 'duplicateQuery' is declared twice in class 'TheCodingMachine\GraphQLite\Fixtures\DuplicateQueries\TestControllerWithDuplicateQuery'. First in 'TheCodingMachine\GraphQLite\Fixtures\DuplicateQueries\TestControllerWithDuplicateQuery::testDuplicateQuery1()', second in 'TheCodingMachine\GraphQLite\Fixtures\DuplicateQueries\TestControllerWithDuplicateQuery::testDuplicateQuery2()'"); @@ -265,8 +249,8 @@ public function testDuplicateQueryInTwoControllersException(): void ); $factory->setAuthenticationService(new VoidAuthenticationService()) ->setAuthorizationService(new VoidAuthorizationService()) - ->addControllerNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\DuplicateQueriesInTwoControllers') - ->addTypeNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); + ->addNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\DuplicateQueriesInTwoControllers') + ->addNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration'); $this->expectException(DuplicateMappingException::class); $this->expectExceptionMessage("The query/mutation 'duplicateQuery' is declared twice: in class 'TheCodingMachine\\GraphQLite\\Fixtures\\DuplicateQueriesInTwoControllers\\TestControllerWithDuplicateQuery1' and in class 'TheCodingMachine\\GraphQLite\\Fixtures\\DuplicateQueriesInTwoControllers\\TestControllerWithDuplicateQuery2"); diff --git a/tests/Utils/NsTest.php b/tests/Utils/NsTest.php deleted file mode 100644 index b46d8bfe6d..0000000000 --- a/tests/Utils/NsTest.php +++ /dev/null @@ -1,145 +0,0 @@ -cache = new Psr16Cache(new ArrayAdapter()); - $this->namespace = 'TheCodingMachine\GraphQLite\Fixtures\Types'; - $this->finder = new ComposerFinder(); - $this->globTTL = 10; - } - - #[DataProvider('loadsClassListProvider')] - public function testLoadsClassList(array $expectedClasses, string $namespace): void - { - $ns = new NS( - namespace: $namespace, - cache: $this->cache, - finder: $this->finder, - globTTL: null, - ); - - self::assertEqualsCanonicalizing($expectedClasses, array_keys($ns->getClassList())); - } - - public static function loadsClassListProvider(): iterable - { - yield 'autoload' => [ - [ - TestFactory::class, - GetterSetterType::class, - FooType::class, - MagicGetterSetterType::class, - FooExtendType::class, - NoTypeAnnotation::class, - AbstractFooType::class, - EnumType::class - ], - 'TheCodingMachine\GraphQLite\Fixtures\Types', - ]; - - // The class should be ignored. - yield 'incorrect namespace class without autoload' => [ - [], - 'TheCodingMachine\GraphQLite\Fixtures\BadNamespace', - ]; - } - - public function testCaching(): void - { - $ns = new NS( - namespace: $this->namespace, - cache: $this->cache, - finder: $this->finder, - globTTL: $this->globTTL, - ); - self::assertNotNull($ns->getClassList()); - - // create with mock finder to test cache - $finder = $this->createMock(FinderInterface::class); - $finder->expects(self::never())->method('inNamespace')->willReturnSelf(); - $ns = new NS( - namespace: $this->namespace, - cache: $this->cache, - finder: $finder, - globTTL: $this->globTTL, - ); - self::assertNotNull($ns->getClassList()); - } - - public function testCachingWithInvalidKey(): void - { - $exception = new class extends Exception implements InvalidArgumentException { - }; - $cache = $this->createMock(CacheInterface::class); - $cache->expects(self::once())->method('get')->willThrowException($exception); - $cache->expects(self::once())->method('set')->willThrowException($exception); - $ns = new NS( - namespace: $this->namespace, - cache: $cache, - finder: $this->finder, - globTTL: $this->globTTL, - ); - $ns->getClassList(); - } - - public function testCachingWithInvalidCache(): void - { - $cache = $this->createMock(CacheInterface::class); - $cache->expects(self::once())->method('get')->willReturn(['foo']); - $ns = new NS( - namespace: $this->namespace, - cache: $cache, - finder: $this->finder, - globTTL: $this->globTTL, - ); - $classList = $ns->getClassList(); - self::assertNotNull($classList); - self::assertNotEmpty($classList); - } - - public function testFinderWithUnexpectedOutput() { - - $finder = $this->createMock(FinderInterface::class); - $finder->expects(self::once())->method('inNamespace')->willReturnSelf(); - $finder->expects(self::once())->method('getIterator')->willReturn(new \ArrayIterator([ 'test' => new \ReflectionException()])); - $ns = new NS( - namespace: $this->namespace, - cache: $this->cache, - finder: $finder, - globTTL: $this->globTTL, - ); - $classList = $ns->getClassList(); - self::assertNotNull($classList); - self::assertEmpty($classList);} -} diff --git a/website/docs/CHANGELOG.md b/website/docs/CHANGELOG.md index fc3f57dba6..bb83ceb4a0 100644 --- a/website/docs/CHANGELOG.md +++ b/website/docs/CHANGELOG.md @@ -4,6 +4,24 @@ title: Changelog sidebar_label: Changelog --- +## 7.1.0 + +### Breaking Changes + +- #698 Removes some methods and classes, namely: + - Deprecated `SchemaFactory::addControllerNamespace()` and `SchemaFactory::addTypeNamespace()` in favor of `SchemaFactory::addNamespace()` + - Deprecated `SchemaFactory::setGlobTTL()` in favor of `SchemaFactory::devMode()` and `SchemaFactory::prodMode()` + - Removed `FactoryContext::get*TTL()` and `RootTypeMapperFactoryContext::get*TTL()` as GraphQLite no longer uses TTLs to invalidate caches + - Removed `StaticClassListTypeMapper` in favor of `ClassFinderTypeMapper` used with `StaticClassFinder` + - Renamed `GlobTypeMapper` to `ClassFinderTypeMapper` + - Renamed `SchemaFactory::setClassBoundCacheContractFactory()` to `SchemaFactory::setClassBoundCache()`, + `FactoryContext::getClassBoundCacheContractFactory()` to `FactoryContext::getClassBoundCache()` and changed their signatures + - Removed `RootTypeMapperFactoryContext::getTypeNamespaces()` in favor of `RootTypeMapperFactoryContext::getClassFinder()` + +### Improvements + +- #698 Performance optimizations and caching in development environments (`devMode()`). @oprypkhantc + ## 7.0.0 ### Breaking Changes diff --git a/website/docs/input-types.mdx b/website/docs/input-types.mdx index c0efa59e87..9b5be40278 100644 --- a/website/docs/input-types.mdx +++ b/website/docs/input-types.mdx @@ -55,7 +55,7 @@ You are running into this error because GraphQLite does not know how to handle t In GraphQL, an object passed in parameter of a query or mutation (or any field) is called an **Input Type**. -There are two ways for declaring that type, in GraphQLite: using the [`#[Input]` attribute](input-attribute) or a [Factory method](factory). +There are two ways for declaring that type, in GraphQLite: using the [`#[Input]` attribute](#input-attribute) or a [Factory method](#factory). ## #\[Input\] Attribute diff --git a/website/docs/other-frameworks.mdx b/website/docs/other-frameworks.mdx index 2bd411b0e1..e2684c0e9f 100644 --- a/website/docs/other-frameworks.mdx +++ b/website/docs/other-frameworks.mdx @@ -35,8 +35,7 @@ use TheCodingMachine\GraphQLite\SchemaFactory; // $cache is a PSR-16 compatible cache // $container is a PSR-11 compatible container $factory = new SchemaFactory($cache, $container); -$factory->addControllerNamespace('App\\Controllers') - ->addTypeNamespace('App'); +$factory->addNamespace('App'); $schema = $factory->createSchema(); ``` @@ -106,8 +105,7 @@ use Kcs\ClassFinder\Finder\ComposerFinder; use TheCodingMachine\GraphQLite\SchemaFactory; $factory = new SchemaFactory($cache, $container); -$factory->addControllerNamespace('App\\Controllers') - ->addTypeNamespace('App') +$factory->addNamespace('App') ->setFinder( (new ComposerFinder())->useAutoloading(false) ); @@ -129,8 +127,7 @@ use TheCodingMachine\GraphQLite\Context\Context; // $cache is a PSR-16 compatible cache. // $container is a PSR-11 compatible container. $factory = new SchemaFactory($cache, $container); -$factory->addControllerNamespace('App\\Controllers') - ->addTypeNamespace('App'); +$factory->addNamespace('App'); $schema = $factory->createSchema(); @@ -287,8 +284,7 @@ return new Picotainer([ Schema::class => function(ContainerInterface $container) { // The magic happens here. We create a schema using GraphQLite SchemaFactory. $factory = new SchemaFactory($container->get(CacheInterface::class), $container); - $factory->addControllerNamespace('App\\Controllers'); - $factory->addTypeNamespace('App'); + $factory->addNamespace('App'); return $factory->createSchema(); } ]); diff --git a/website/docs/validation.mdx b/website/docs/validation.mdx index 13a05188fd..dde8a9413a 100644 --- a/website/docs/validation.mdx +++ b/website/docs/validation.mdx @@ -154,8 +154,7 @@ To get started with validation on input types defined by an `#[Input]` attribute ```php $factory = new SchemaFactory($cache, $this->container); -$factory->addControllerNamespace('App\\Controllers'); -$factory->addTypeNamespace('App'); +$factory->addNamespace('App'); // Register your validator $factory->setInputTypeValidator($this->container->get('your_validator')); $factory->createSchema(); diff --git a/website/versioned_docs/version-6.0/input-types.mdx b/website/versioned_docs/version-6.0/input-types.mdx index f2c62afd40..c5737d9176 100644 --- a/website/versioned_docs/version-6.0/input-types.mdx +++ b/website/versioned_docs/version-6.0/input-types.mdx @@ -109,7 +109,7 @@ You are running into this error because GraphQLite does not know how to handle t In GraphQL, an object passed in parameter of a query or mutation (or any field) is called an **Input Type**. -There are two ways for declaring that type, in GraphQLite: using the [`#[Input]` attribute](input-attribute) or a [Factory method](factory). +There are two ways for declaring that type, in GraphQLite: using the [`#[Input]` attribute](#input-attribute) or a [Factory method](#factory). ## #\[Input\] Attribute diff --git a/website/versioned_docs/version-6.1/input-types.mdx b/website/versioned_docs/version-6.1/input-types.mdx index 06754d97a1..48369cda90 100644 --- a/website/versioned_docs/version-6.1/input-types.mdx +++ b/website/versioned_docs/version-6.1/input-types.mdx @@ -59,7 +59,7 @@ You are running into this error because GraphQLite does not know how to handle t In GraphQL, an object passed in parameter of a query or mutation (or any field) is called an **Input Type**. -There are two ways for declaring that type, in GraphQLite: using the [`#[Input]` attribute](input-attribute) or a [Factory method](factory). +There are two ways for declaring that type, in GraphQLite: using the [`#[Input]` attribute](#input-attribute) or a [Factory method](#factory). ## #\[Input\] Attribute diff --git a/website/versioned_docs/version-7.0.0/input-types.mdx b/website/versioned_docs/version-7.0.0/input-types.mdx index f2c62afd40..c5737d9176 100644 --- a/website/versioned_docs/version-7.0.0/input-types.mdx +++ b/website/versioned_docs/version-7.0.0/input-types.mdx @@ -109,7 +109,7 @@ You are running into this error because GraphQLite does not know how to handle t In GraphQL, an object passed in parameter of a query or mutation (or any field) is called an **Input Type**. -There are two ways for declaring that type, in GraphQLite: using the [`#[Input]` attribute](input-attribute) or a [Factory method](factory). +There are two ways for declaring that type, in GraphQLite: using the [`#[Input]` attribute](#input-attribute) or a [Factory method](#factory). ## #\[Input\] Attribute From 56ec3f5b695c316412c25d1fa1bf823df52257de Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 00:59:36 -0400 Subject: [PATCH 077/108] Bump JamesIves/github-pages-deploy-action from 4.6.1 to 4.6.8 (#703) Bumps [JamesIves/github-pages-deploy-action](https://github.com/jamesives/github-pages-deploy-action) from 4.6.1 to 4.6.8. - [Release notes](https://github.com/jamesives/github-pages-deploy-action/releases) - [Commits](https://github.com/jamesives/github-pages-deploy-action/compare/v4.6.1...v4.6.8) --- updated-dependencies: - dependency-name: JamesIves/github-pages-deploy-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> @dependabot rebase --- .github/workflows/doc_generation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/doc_generation.yml b/.github/workflows/doc_generation.yml index 4c73a105b5..ae736b29a7 100644 --- a/.github/workflows/doc_generation.yml +++ b/.github/workflows/doc_generation.yml @@ -36,7 +36,7 @@ jobs: - name: "Deploy website" if: "${{ github.event_name == 'push' || github.event_name == 'release' }}" - uses: JamesIves/github-pages-deploy-action@v4.6.1 + uses: JamesIves/github-pages-deploy-action@v4.6.8 with: token: ${{ secrets.GITHUB_TOKEN }} branch: gh-pages # The branch the action should deploy to. From 68de6c957349591e93307e02205799b0c4d74c64 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 17:18:54 -0400 Subject: [PATCH 078/108] Bump codecov/codecov-action from 4.5.0 to 4.6.0 (#704) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.5.0 to 4.6.0. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v4.5.0...v4.6.0) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 6f68e1304a..045f00c336 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -84,7 +84,7 @@ jobs: path: "build" overwrite: true - - uses: codecov/codecov-action@v4.5.0 # upload the coverage to codecov + - uses: codecov/codecov-action@v4.6.0 # upload the coverage to codecov with: fail_ci_if_error: false # optional (default = false) - Need CODECOV_TOKEN # Do not upload in forks, and only on php8.3, latest deps From 03499a31a7d3783847a77b76434c1471f14bc0fa Mon Sep 17 00:00:00 2001 From: sudevva Date: Sun, 13 Oct 2024 09:44:04 +0700 Subject: [PATCH 079/108] Fix bug with prefetching on different nesting levels (#702) * Fix bug with prefetching on different nesting levels Allow Promise to be returned from prefetchMethod with returnRequested: true * revert to previous Prefetch behavior * allow promise to be returned in methods * add nesting preload test * CR fixes * CR fixes * add Promise type mapping docs --------- Co-authored-by: Andrii Sudiev --- src/Parameters/PrefetchDataParameter.php | 23 ++-- src/PrefetchBuffer.php | 35 +++--- src/QueryField.php | 68 ++++++----- .../Controllers/BlogController.php | 21 ++++ tests/Fixtures/Integration/Models/Blog.php | 83 +++++++++++++ tests/Fixtures/Integration/Models/Comment.php | 23 ++++ .../Integration/Types/CompanyType.php | 11 +- tests/Fixtures/Integration/Types/PostType.php | 37 ++++++ tests/Integration/EndToEndTest.php | 115 ++++++++++++++++++ .../Parameters/PrefetchDataParameterTest.php | 9 +- tests/SchemaFactoryTest.php | 2 + website/docs/type-mapping.mdx | 41 +++++++ 12 files changed, 408 insertions(+), 60 deletions(-) create mode 100644 tests/Fixtures/Integration/Controllers/BlogController.php create mode 100644 tests/Fixtures/Integration/Models/Blog.php create mode 100644 tests/Fixtures/Integration/Models/Comment.php diff --git a/src/Parameters/PrefetchDataParameter.php b/src/Parameters/PrefetchDataParameter.php index 4b233c5e3c..9aaab6c7a8 100644 --- a/src/Parameters/PrefetchDataParameter.php +++ b/src/Parameters/PrefetchDataParameter.php @@ -52,24 +52,31 @@ public function resolve(object|null $source, array $args, mixed $context, Resolv // So we record all of these ->resolve() calls, collect them together and when a value is actually // needed, GraphQL calls the callback of Deferred below. That's when we call the prefetch method, // already knowing all the requested fields (source-arguments combinations). - return new Deferred(function () use ($info, $context, $args, $prefetchBuffer) { - if (! $prefetchBuffer->hasResult($args, $info)) { - $prefetchResult = $this->computePrefetch($args, $context, $info, $prefetchBuffer); - - $prefetchBuffer->storeResult($prefetchResult, $args, $info); + return new Deferred(function () use ($source, $info, $context, $args, $prefetchBuffer) { + if (! $prefetchBuffer->hasResult($source)) { + $this->processPrefetch($args, $context, $info, $prefetchBuffer); } - return $prefetchResult ?? $prefetchBuffer->getResult($args, $info); + $result = $prefetchBuffer->getResult($source); + // clear internal storage + $prefetchBuffer->purgeResult($source); + return $result; }); } /** @param array $args */ - private function computePrefetch(array $args, mixed $context, ResolveInfo $info, PrefetchBuffer $prefetchBuffer): mixed + private function processPrefetch(array $args, mixed $context, ResolveInfo $info, PrefetchBuffer $prefetchBuffer): void { $sources = $prefetchBuffer->getObjectsByArguments($args, $info); + $prefetchBuffer->purge($args, $info); $toPassPrefetchArgs = QueryField::paramsToArguments($this->fieldName, $this->parameters, null, $args, $context, $info, $this->resolver); - return ($this->resolver)($sources, ...$toPassPrefetchArgs); + $resolvedValues = ($this->resolver)($sources, ...$toPassPrefetchArgs); + + foreach ($sources as $source) { + // map results to each source to support old prefetch behavior + $prefetchBuffer->storeResult($source, $resolvedValues); + } } /** @inheritDoc */ diff --git a/src/PrefetchBuffer.php b/src/PrefetchBuffer.php index 8b1afb02a0..69ebcb706b 100644 --- a/src/PrefetchBuffer.php +++ b/src/PrefetchBuffer.php @@ -5,8 +5,8 @@ namespace TheCodingMachine\GraphQLite; use GraphQL\Type\Definition\ResolveInfo; +use SplObjectStorage; -use function array_key_exists; use function md5; use function serialize; @@ -18,8 +18,13 @@ class PrefetchBuffer /** @var array> An array of buffered, indexed by hash of arguments. */ private array $objects = []; - /** @var array An array of prefetch method results, indexed by hash of arguments. */ - private array $results = []; + /** @var SplObjectStorage A Storage of prefetch method results, holds source to resolved values. */ + private SplObjectStorage $results; + + public function __construct() + { + $this->results = new SplObjectStorage(); + } /** @param array $arguments The input arguments passed from GraphQL to the field. */ public function register( @@ -66,28 +71,28 @@ public function purge( unset($this->objects[$this->computeHash($arguments, $info)]); } - /** @param array $arguments The input arguments passed from GraphQL to the field. */ public function storeResult( + object $source, mixed $result, - array $arguments, - ResolveInfo|null $info = null, ): void { - $this->results[$this->computeHash($arguments, $info)] = $result; + $this->results->offsetSet($source, $result); } - /** @param array $arguments The input arguments passed from GraphQL to the field. */ public function hasResult( - array $arguments, - ResolveInfo|null $info = null, + object $source, ): bool { - return array_key_exists($this->computeHash($arguments, $info), $this->results); + return $this->results->offsetExists($source); } - /** @param array $arguments The input arguments passed from GraphQL to the field. */ public function getResult( - array $arguments, - ResolveInfo|null $info = null, + object $source, ): mixed { - return $this->results[$this->computeHash($arguments, $info)]; + return $this->results->offsetGet($source); + } + + public function purgeResult( + object $source, + ): void { + $this->results->offsetUnset($source); } } diff --git a/src/QueryField.php b/src/QueryField.php index b95a9152f4..5a224b70a9 100644 --- a/src/QueryField.php +++ b/src/QueryField.php @@ -4,11 +4,9 @@ namespace TheCodingMachine\GraphQLite; -use GraphQL\Deferred; +use Closure; use GraphQL\Error\ClientAware; use GraphQL\Executor\Promise\Adapter\SyncPromise; -use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter; -use GraphQL\Executor\Promise\Promise; use GraphQL\Language\AST\FieldDefinitionNode; use GraphQL\Type\Definition\FieldDefinition; use GraphQL\Type\Definition\ListOfType; @@ -22,9 +20,6 @@ use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; use TheCodingMachine\GraphQLite\Parameters\SourceParameter; -use function array_filter; -use function array_map; - /** * A GraphQL field that maps to a PHP method automatically. * @@ -79,35 +74,15 @@ public function __construct( $callResolver = function (...$args) use ($originalResolver, $source, $resolver) { $result = $resolver($source, ...$args); - try { - $this->assertReturnType($result); - } catch (TypeMismatchRuntimeException $e) { - $e->addInfo($this->name, $originalResolver->toString()); - - throw $e; - } - - return $result; + return $this->resolveWithPromise($result, $originalResolver); }; - $deferred = (bool) array_filter($toPassArgs, static fn (mixed $value) => $value instanceof SyncPromise); - // GraphQL allows deferring resolving the field's value using promises, i.e. they call the resolve // function ahead of time for all of the fields (allowing us to gather all calls and do something // in batch, like prefetch) and then resolve the promises as needed. To support that for prefetch, // we're checking if any of the resolved parameters returned a promise. If they did, we know // that the value should also be resolved using a promise, so we're wrapping it in one. - return $deferred ? new Deferred(static function () use ($toPassArgs, $callResolver) { - $syncPromiseAdapter = new SyncPromiseAdapter(); - - // Wait for every deferred parameter. - $toPassArgs = array_map( - static fn (mixed $value) => $value instanceof SyncPromise ? $syncPromiseAdapter->wait(new Promise($value, $syncPromiseAdapter)) : $value, - $toPassArgs, - ); - - return $callResolver(...$toPassArgs); - }) : $callResolver(...$toPassArgs); + return $this->deferred($toPassArgs, $callResolver); }; $config += $additionalConfig; @@ -115,6 +90,23 @@ public function __construct( parent::__construct($config); } + private function resolveWithPromise(mixed $result, ResolverInterface $originalResolver): mixed + { + if ($result instanceof SyncPromise) { + return $result->then(fn ($resolvedValue) => $this->resolveWithPromise($resolvedValue, $originalResolver)); + } + + try { + $this->assertReturnType($result); + } catch (TypeMismatchRuntimeException $e) { + $e->addInfo($this->name, $originalResolver->toString()); + + throw $e; + } + + return $result; + } + /** * This method checks the returned value of the resolver to be sure it matches the documented return type. * We are sure the returned value is of the correct type... except if the return type is type-hinted as an array. @@ -204,4 +196,24 @@ public static function paramsToArguments(string $name, array $parameters, object return $toPassArgs; } + + /** + * @param array $toPassArgs + * Create Deferred if any of arguments contain promise + */ + public static function deferred(array $toPassArgs, Closure $callResolver): mixed + { + $deferredArgument = null; + foreach ($toPassArgs as $position => $toPassArg) { + if ($toPassArg instanceof SyncPromise) { + $deferredArgument = $toPassArg->then(static function ($resolvedValue) use ($toPassArgs, $position, $callResolver) { + $toPassArgs[$position] = $resolvedValue; + return self::deferred($toPassArgs, $callResolver); + }); + break; + } + } + + return $deferredArgument ?? $callResolver(...$toPassArgs); + } } diff --git a/tests/Fixtures/Integration/Controllers/BlogController.php b/tests/Fixtures/Integration/Controllers/BlogController.php new file mode 100644 index 0000000000..1f358aa69b --- /dev/null +++ b/tests/Fixtures/Integration/Controllers/BlogController.php @@ -0,0 +1,21 @@ +id; + } + + /** + * @param Post[][] $prefetchedPosts + * + * @return Post[] + */ + #[Field] + public function getPosts( + #[Prefetch('prefetchPosts')] + array $prefetchedPosts, + ): array { + return $prefetchedPosts[$this->id]; + } + + /** @param Blog[][] $prefetchedSubBlogs */ + #[Field(outputType: '[Blog!]!')] + public function getSubBlogs( + #[Prefetch('prefetchSubBlogs')] + array $prefetchedSubBlogs, + ): Deferred { + return new Deferred(fn () => $prefetchedSubBlogs[$this->id]); + } + + /** + * @param Blog[] $blogs + * + * @return Post[][] + */ + public static function prefetchPosts(iterable $blogs): array + { + $posts = []; + foreach ($blogs as $blog) { + $blogId = $blog->getId(); + $posts[$blog->getId()] = [ + new Post('post-' . $blogId . '.1'), + new Post('post-' . $blogId . '.2'), + ]; + } + + return $posts; + } + + /** + * @param Blog[] $blogs + * + * @return Blog[][] + */ + public static function prefetchSubBlogs(iterable $blogs): array + { + $subBlogs = []; + foreach ($blogs as $blog) { + $blogId = $blog->getId(); + $subBlogId = $blogId * 10; + $subBlogs[$blog->id] = [new Blog($subBlogId)]; + } + + return $subBlogs; + } +} diff --git a/tests/Fixtures/Integration/Models/Comment.php b/tests/Fixtures/Integration/Models/Comment.php new file mode 100644 index 0000000000..094e6e571c --- /dev/null +++ b/tests/Fixtures/Integration/Models/Comment.php @@ -0,0 +1,23 @@ +text; + } +} diff --git a/tests/Fixtures/Integration/Types/CompanyType.php b/tests/Fixtures/Integration/Types/CompanyType.php index 31a042479f..fbada7f2aa 100644 --- a/tests/Fixtures/Integration/Types/CompanyType.php +++ b/tests/Fixtures/Integration/Types/CompanyType.php @@ -13,22 +13,27 @@ #[ExtendType(class:Company::class)] class CompanyType { - #[Field] public function getName(Company $company): string { return $company->name; } + /** @param Contact[] $contacts */ #[Field] public function getContact( Company $company, #[Prefetch('prefetchContacts')] - array $contacts - ): ?Contact { + array $contacts, + ): Contact|null { return $contacts[$company->name] ?? null; } + /** + * @param Company[] $companies + * + * @return Contact[] + */ public static function prefetchContacts(array $companies): array { $contacts = []; diff --git a/tests/Fixtures/Integration/Types/PostType.php b/tests/Fixtures/Integration/Types/PostType.php index 565c783ecb..45e9576bb1 100644 --- a/tests/Fixtures/Integration/Types/PostType.php +++ b/tests/Fixtures/Integration/Types/PostType.php @@ -4,8 +4,13 @@ namespace TheCodingMachine\GraphQLite\Fixtures\Integration\Types; +use Exception; +use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter; +use GraphQL\Executor\Promise\Promise; use TheCodingMachine\GraphQLite\Annotations\ExtendType; use TheCodingMachine\GraphQLite\Annotations\Field; +use TheCodingMachine\GraphQLite\Annotations\Prefetch; +use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Comment; use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Post; #[ExtendType(class:Post::class)] @@ -22,4 +27,36 @@ public function getTitle(Post $post): string { return $post->title; } + + /** + * @param Comment[][] $prefetchedComments + * + * @return Comment[] + */ + #[Field] + public function getComments( + Post $post, + #[Prefetch('prefetchComments')] + array $prefetchedComments, + ): array { + return $prefetchedComments[$post->title]; + } + + /** + * @param Post[] $posts + * + * @return Promise[] + * + * @throws Exception + */ + public static function prefetchComments(array $posts): Promise + { + $syncPromiseAdapter = new SyncPromiseAdapter(); + $result = []; + foreach ($posts as $post) { + $result[$post->title] = [new Comment('comment for ' . $post->title)]; + } + + return $syncPromiseAdapter->createFulfilled($result); + } } diff --git a/tests/Integration/EndToEndTest.php b/tests/Integration/EndToEndTest.php index 0d9f2086c4..24a055dd1a 100644 --- a/tests/Integration/EndToEndTest.php +++ b/tests/Integration/EndToEndTest.php @@ -2339,4 +2339,119 @@ public function testEndToEndSubscriptionWithInput(): void 'contactAddedWithFilter' => null, ], $this->getSuccessResult($result)); } + + public function testPrefetchingOfSameTypeInDifferentNestingLevels(): void + { + $schema = $this->mainContainer->get(Schema::class); + assert($schema instanceof Schema); + + $schema->assertValid(); + + $queryString = ' + query { + blogs { + id + subBlogs { + id + posts { + title + comments { + text + } + } + } + posts { + title + comments { + text + } + } + } + } + '; + + $result = GraphQL::executeQuery( + $schema, + $queryString, + null, + new Context(), + ); + + $this->assertSame([ + 'blogs' => [ + [ + 'id' => '1', + 'subBlogs' => [ + [ + 'id' => '10', + 'posts' => [ + [ + 'title' => 'post-10.1', + 'comments' => [ + ['text' => 'comment for post-10.1'], + ], + ], + [ + 'title' => 'post-10.2', + 'comments' => [ + ['text' => 'comment for post-10.2'], + ], + ], + ], + ], + ], + 'posts' => [ + [ + 'title' => 'post-1.1', + 'comments' => [ + ['text' => 'comment for post-1.1'], + ], + ], + [ + 'title' => 'post-1.2', + 'comments' => [ + ['text' => 'comment for post-1.2'], + ], + ], + ], + ], + [ + 'id' => '2', + 'subBlogs' => [ + [ + 'id' => '20', + 'posts' => [ + [ + 'title' => 'post-20.1', + 'comments' => [ + ['text' => 'comment for post-20.1'], + ], + ], + [ + 'title' => 'post-20.2', + 'comments' => [ + ['text' => 'comment for post-20.2'], + ], + ], + ], + ], + ], + 'posts' => [ + [ + 'title' => 'post-2.1', + 'comments' => [ + ['text' => 'comment for post-2.1'], + ], + ], + [ + 'title' => 'post-2.2', + 'comments' => [ + ['text' => 'comment for post-2.2'], + ], + ], + ], + ], + ], + ], $this->getSuccessResult($result)); + } } diff --git a/tests/Parameters/PrefetchDataParameterTest.php b/tests/Parameters/PrefetchDataParameterTest.php index 9d241b7ccc..a7e875c76a 100644 --- a/tests/Parameters/PrefetchDataParameterTest.php +++ b/tests/Parameters/PrefetchDataParameterTest.php @@ -2,7 +2,6 @@ namespace TheCodingMachine\GraphQLite\Parameters; -use Generator; use GraphQL\Deferred; use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter; use GraphQL\Executor\Promise\Promise; @@ -11,8 +10,6 @@ use PHPUnit\Framework\TestCase; use stdClass; use TheCodingMachine\GraphQLite\Context\Context; -use TheCodingMachine\GraphQLite\Middlewares\MissingAuthorizationException; -use TheCodingMachine\GraphQLite\Security\AuthenticationServiceInterface; use TheCodingMachine\GraphQLite\Types\ArgumentResolver; class PrefetchDataParameterTest extends TestCase @@ -32,7 +29,7 @@ public function testResolveWithExistingResult(): void ]; $buffer = $context->getPrefetchBuffer($parameter); - $buffer->storeResult($prefetchResult, $args); + $buffer->storeResult($source, $prefetchResult); $resolvedParameterPromise = $parameter->resolve($source, $args, $context, $this->createStub(ResolveInfo::class)); @@ -71,10 +68,10 @@ public function testResolveWithoutExistingResult(): void $resolvedParameterPromise = $parameter->resolve($source, $args, $context, $this->createStub(ResolveInfo::class)); - self::assertFalse($buffer->hasResult($args)); + self::assertFalse($buffer->hasResult($source)); self::assertSame([$source], $buffer->getObjectsByArguments($args)); self::assertSame($prefetchResult, $this->deferredValue($resolvedParameterPromise)); - self::assertTrue($buffer->hasResult($args)); + self::assertFalse($buffer->hasResult($source)); } private function deferredValue(Deferred $promise): mixed diff --git a/tests/SchemaFactoryTest.php b/tests/SchemaFactoryTest.php index 78defd5b78..03678fa1f9 100644 --- a/tests/SchemaFactoryTest.php +++ b/tests/SchemaFactoryTest.php @@ -18,6 +18,7 @@ use TheCodingMachine\GraphQLite\Containers\BasicAutoWiringContainer; use TheCodingMachine\GraphQLite\Containers\EmptyContainer; use TheCodingMachine\GraphQLite\Fixtures\Integration\Controllers\ContactController; +use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Comment; use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Company; use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Contact; use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Post; @@ -118,6 +119,7 @@ public function testCreateSchemaOnlyWithFactories(): void ContactFactory::class, ContactOtherType::class, ContactType::class, + Comment::class, Post::class, PostType::class, Company::class, diff --git a/website/docs/type-mapping.mdx b/website/docs/type-mapping.mdx index 26127719d6..d5b6c4dc29 100644 --- a/website/docs/type-mapping.mdx +++ b/website/docs/type-mapping.mdx @@ -330,6 +330,47 @@ query { } ``` +## Promise mapping + +GraphQL includes a native \GraphQL\Deferred type. +You can map the return type by adding a detailed `@return` statement in the PHPDoc. +An alternative to the `@return` statement is using `#[Field(outputType: SomeGQLType)]`. + +All the previously mentioned mappings work with Promises, except when a return type is explicitly declared +in the method signature. + +This allows you to use \Overblog\DataLoader\DataLoader as an alternative +for resolving N+1 query issues and caching intermediate results. + +```php +#[Type] +class Product +{ + // ... + + /** + * @return string + */ + #[Field] + public function getName(): Deferred + { + return new Deferred(fn() => $this->name); + } + + #[Field(outputType: "Float")] + public function getPrice(): Deferred + { + return new Deferred(fn() => $this->price); + } + + #[Field(outputType: "[String!]!")] + public function getCategories(#[Autowire('categoryDataLoader')] DataLoader $categoryDataLoader): SyncPromise + { + return $categoryDataLoader->load($this->id)->adoptedPromise; + } +} +``` + ## More scalar types Available in GraphQLite 4.0+ From 9a6d66f99fb884e3f24a40be60053df04d1b9ae8 Mon Sep 17 00:00:00 2001 From: Andrey Date: Wed, 23 Oct 2024 08:00:02 +0300 Subject: [PATCH 080/108] Fix typo in fine-grained-security.mdx (#705) --- website/versioned_docs/version-7.0.0/fine-grained-security.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/versioned_docs/version-7.0.0/fine-grained-security.mdx b/website/versioned_docs/version-7.0.0/fine-grained-security.mdx index f5d2fff032..3b3aed272f 100644 --- a/website/versioned_docs/version-7.0.0/fine-grained-security.mdx +++ b/website/versioned_docs/version-7.0.0/fine-grained-security.mdx @@ -417,5 +417,5 @@ If you are using Symfony, you will [create a custom voter](https://symfony.com/d If you are using Laravel, you will [create a Gate or a Policy](https://laravel.com/docs/6.x/authorization). If you are using another framework, you need to know that the `is_granted` function simply forwards the call to -the `isAllowed` method of the configured `AuthorizationSerice`. See [Connecting GraphQLite to your framework's security module +the `isAllowed` method of the configured `AuthorizationService`. See [Connecting GraphQLite to your framework's security module ](implementing-security.md) for more details From 0f1a112fe46a131fb6564b2f1001b5dc6d35b075 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Nov 2024 16:37:13 -0500 Subject: [PATCH 081/108] Bump JamesIves/github-pages-deploy-action from 4.6.8 to 4.6.9 (#706) Bumps [JamesIves/github-pages-deploy-action](https://github.com/jamesives/github-pages-deploy-action) from 4.6.8 to 4.6.9. - [Release notes](https://github.com/jamesives/github-pages-deploy-action/releases) - [Commits](https://github.com/jamesives/github-pages-deploy-action/compare/v4.6.8...v4.6.9) --- updated-dependencies: - dependency-name: JamesIves/github-pages-deploy-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/doc_generation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/doc_generation.yml b/.github/workflows/doc_generation.yml index ae736b29a7..3b0b3ff239 100644 --- a/.github/workflows/doc_generation.yml +++ b/.github/workflows/doc_generation.yml @@ -36,7 +36,7 @@ jobs: - name: "Deploy website" if: "${{ github.event_name == 'push' || github.event_name == 'release' }}" - uses: JamesIves/github-pages-deploy-action@v4.6.8 + uses: JamesIves/github-pages-deploy-action@v4.6.9 with: token: ${{ secrets.GITHUB_TOKEN }} branch: gh-pages # The branch the action should deploy to. From d45b9b9555f360d995ebf3cb38d0cf7999bd394e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 16:40:26 -0500 Subject: [PATCH 082/108] Bump codecov/codecov-action from 4.6.0 to 5.0.2 (#710) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.6.0 to 5.0.2. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v4.6.0...v5.0.2) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 045f00c336..69f024d6ce 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -84,7 +84,7 @@ jobs: path: "build" overwrite: true - - uses: codecov/codecov-action@v4.6.0 # upload the coverage to codecov + - uses: codecov/codecov-action@v5.0.2 # upload the coverage to codecov with: fail_ci_if_error: false # optional (default = false) - Need CODECOV_TOKEN # Do not upload in forks, and only on php8.3, latest deps From 406a0dca3bc93090271a03fa3ba4cb082067aa91 Mon Sep 17 00:00:00 2001 From: Andrii Dembitskyi Date: Wed, 20 Nov 2024 06:32:15 +0200 Subject: [PATCH 083/108] :bug: [#711] Don't reuse cache for differently configured class finder instances (#712) --- .../Cache/HardClassFinderComputedCache.php | 6 +- .../SnapshotClassFinderComputedCache.php | 3 + src/Discovery/ClassFinder.php | 5 ++ src/Discovery/KcsClassFinder.php | 9 ++- src/Discovery/StaticClassFinder.php | 10 +++ src/SchemaFactory.php | 5 +- tests/AbstractQueryProvider.php | 9 ++- .../HardClassFinderComputedCacheTest.php | 59 +++++++++++++-- .../SnapshotClassFinderComputedCacheTest.php | 51 ++++++------- tests/Discovery/KcsClassFinderTest.php | 7 +- .../TestCustomMapperController.php | 21 ++++++ .../Types/TestCustomMapperObject.php | 18 +++++ .../Controllers/TestLegacyController.php | 17 +++++ .../Types/TestLegacyObject.php | 18 +++++ tests/GlobControllerQueryProviderTest.php | 13 +++- tests/Integration/IntegrationTestCase.php | 15 +++- tests/Mappers/ClassFinderTypeMapperTest.php | 73 ++++++++++++++++++- 17 files changed, 285 insertions(+), 54 deletions(-) create mode 100644 tests/Fixtures/ClassFinderCustomTypeMapper/Controllers/TestCustomMapperController.php create mode 100644 tests/Fixtures/ClassFinderCustomTypeMapper/Types/TestCustomMapperObject.php create mode 100644 tests/Fixtures/ClassFinderTypeMapper/Controllers/TestLegacyController.php create mode 100644 tests/Fixtures/ClassFinderTypeMapper/Types/TestLegacyObject.php diff --git a/src/Discovery/Cache/HardClassFinderComputedCache.php b/src/Discovery/Cache/HardClassFinderComputedCache.php index 472aa5a58a..70d303a1ff 100644 --- a/src/Discovery/Cache/HardClassFinderComputedCache.php +++ b/src/Discovery/Cache/HardClassFinderComputedCache.php @@ -8,12 +8,13 @@ use ReflectionClass; use TheCodingMachine\GraphQLite\Discovery\ClassFinder; +use function sprintf; + class HardClassFinderComputedCache implements ClassFinderComputedCache { public function __construct( private readonly CacheInterface $cache, - ) - { + ) { } /** @@ -32,6 +33,7 @@ public function compute( callable $reduce, ): mixed { + $key = sprintf('%s.%s', $key, $classFinder->hash()); $result = $this->cache->get($key); if ($result !== null) { diff --git a/src/Discovery/Cache/SnapshotClassFinderComputedCache.php b/src/Discovery/Cache/SnapshotClassFinderComputedCache.php index 3e4b9d2306..46a80b48d0 100644 --- a/src/Discovery/Cache/SnapshotClassFinderComputedCache.php +++ b/src/Discovery/Cache/SnapshotClassFinderComputedCache.php @@ -9,6 +9,8 @@ use TheCodingMachine\GraphQLite\Cache\FilesSnapshot; use TheCodingMachine\GraphQLite\Discovery\ClassFinder; +use function sprintf; + /** * Provides cache for a {@see ClassFinder} based on a {@see filemtime()}. * @@ -49,6 +51,7 @@ public function compute( callable $reduce, ): mixed { + $key = sprintf('%s.%s', $key, $classFinder->hash()); $entries = $this->entries($classFinder, $key . '.entries', $map); return $reduce($entries); diff --git a/src/Discovery/ClassFinder.php b/src/Discovery/ClassFinder.php index 67d2cf3b1a..92a7cbe42b 100644 --- a/src/Discovery/ClassFinder.php +++ b/src/Discovery/ClassFinder.php @@ -11,4 +11,9 @@ interface ClassFinder extends IteratorAggregate { public function withPathFilter(callable $filter): self; + + /** + * Path filter does not affect the hash. + */ + public function hash(): string; } diff --git a/src/Discovery/KcsClassFinder.php b/src/Discovery/KcsClassFinder.php index d54616b8ec..c6939c3578 100644 --- a/src/Discovery/KcsClassFinder.php +++ b/src/Discovery/KcsClassFinder.php @@ -12,8 +12,8 @@ class KcsClassFinder implements ClassFinder { public function __construct( private FinderInterface $finder, - ) - { + private readonly string $hash, + ) { } public function withPathFilter(callable $filter): ClassFinder @@ -29,4 +29,9 @@ public function getIterator(): Traversable { return $this->finder->getIterator(); } + + public function hash(): string + { + return $this->hash; + } } diff --git a/src/Discovery/StaticClassFinder.php b/src/Discovery/StaticClassFinder.php index 7eedb617b0..d04298aa78 100644 --- a/src/Discovery/StaticClassFinder.php +++ b/src/Discovery/StaticClassFinder.php @@ -7,11 +7,16 @@ use ReflectionClass; use Traversable; +use function md5; +use function serialize; + class StaticClassFinder implements ClassFinder { /** @var (callable(string): bool)|null */ private mixed $pathFilter = null; + private string|null $hash = null; + /** @param list $classes */ public function __construct( private readonly array $classes, @@ -41,4 +46,9 @@ public function getIterator(): Traversable yield $class => $classReflection; } } + + public function hash(): string + { + return $this->hash ??= md5(serialize($this->classes)); + } } diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index 3991341b3f..53b43948b6 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -70,6 +70,7 @@ use function array_reverse; use function class_exists; +use function implode; use function md5; use function substr; use function trigger_error; @@ -565,6 +566,8 @@ private function createClassFinder(): ClassFinder $finder = $finder->inNamespace($namespace); } - return new KcsClassFinder($finder); + $hash = md5(implode(',', $this->namespaces)); + + return new KcsClassFinder($finder, $hash); } } diff --git a/tests/AbstractQueryProvider.php b/tests/AbstractQueryProvider.php index 8e8c09fe72..88ae390f47 100644 --- a/tests/AbstractQueryProvider.php +++ b/tests/AbstractQueryProvider.php @@ -29,8 +29,8 @@ use TheCodingMachine\GraphQLite\Discovery\Cache\ClassFinderComputedCache; use TheCodingMachine\GraphQLite\Discovery\Cache\HardClassFinderComputedCache; use TheCodingMachine\GraphQLite\Discovery\ClassFinder; -use TheCodingMachine\GraphQLite\Discovery\StaticClassFinder; use TheCodingMachine\GraphQLite\Discovery\KcsClassFinder; +use TheCodingMachine\GraphQLite\Discovery\StaticClassFinder; use TheCodingMachine\GraphQLite\Fixtures\Mocks\MockResolvableInputObjectType; use TheCodingMachine\GraphQLite\Fixtures\TestObject; use TheCodingMachine\GraphQLite\Fixtures\TestObject2; @@ -67,6 +67,9 @@ use TheCodingMachine\GraphQLite\Types\ResolvableMutableInputInterface; use TheCodingMachine\GraphQLite\Types\TypeResolver; +use function implode; +use function md5; + abstract class AbstractQueryProvider extends TestCase { private $testObjectType; @@ -481,7 +484,9 @@ protected function getClassFinder(array|string $namespaces): ClassFinder $finder = $finder->withFileFinder(new CachedFileFinder(new DefaultFileFinder(), $arrayAdapter)); - return new KcsClassFinder($finder); + $hash = md5(implode(',', (array) $namespaces)); + + return new KcsClassFinder($finder, $hash); } protected function getClassFinderComputedCache(): ClassFinderComputedCache diff --git a/tests/Discovery/Cache/HardClassFinderComputedCacheTest.php b/tests/Discovery/Cache/HardClassFinderComputedCacheTest.php index 407550a0b3..6040d4aabe 100644 --- a/tests/Discovery/Cache/HardClassFinderComputedCacheTest.php +++ b/tests/Discovery/Cache/HardClassFinderComputedCacheTest.php @@ -1,9 +1,12 @@ setLogger(new ExceptionLogger()); @@ -30,8 +35,46 @@ public function testCachesResultOfReduceFunction(): void TestType::class, ]), 'key', - fn (\ReflectionClass $reflection) => $reflection->getShortName(), - fn (array $entries) => [array_values($entries)], + static fn (ReflectionClass $reflection) => $reflection->getShortName(), + static fn (array $entries) => [array_values($entries)], + ); + + $this->assertSame([ + 'FooType', + 'FooExtendType', + 'TestType', + ], $result); + + // Class finder have different class list - result should not be reused from the cache. + // This is necessary to avoid caching issues when there're multiple class finders shares the same cache pool. + [$result] = $classFinderComputedCache->compute( + new StaticClassFinder([FooType::class]), + 'key', + static fn (ReflectionClass $reflection) => $reflection->getShortName(), + static fn (array $entries) => [array_values($entries)], + ); + + $this->assertSame(['FooType'], $result); + } + + public function testReusedCacheBecauseSameList(): void + { + $arrayAdapter = new ArrayAdapter(); + $arrayAdapter->setLogger(new ExceptionLogger()); + $cache = new Psr16Cache($arrayAdapter); + + $classFinderComputedCache = new HardClassFinderComputedCache($cache); + + $classList = [ + FooType::class, + FooExtendType::class, + TestType::class, + ]; + [$result] = $classFinderComputedCache->compute( + new StaticClassFinder($classList), + 'key', + static fn (ReflectionClass $reflection) => $reflection->getShortName(), + static fn (array $entries) => [array_values($entries)], ); $this->assertSame([ @@ -40,13 +83,13 @@ public function testCachesResultOfReduceFunction(): void 'TestType', ], $result); - // Even though the class finder and both functions have changed - the result should still be cached. + // Class finder have the same class list - even both functions have changed - the result should be cached. // This is useful in production, where code and file structure doesn't change. [$result] = $classFinderComputedCache->compute( - new StaticClassFinder([]), + new StaticClassFinder($classList), 'key', - fn (\ReflectionClass $reflection) => self::fail('Should not be called.'), - fn (array $entries) => self::fail('Should not be called.'), + static fn (ReflectionClass $reflection) => self::fail('Should not be called.'), + static fn (array $entries) => self::fail('Should not be called.'), ); $this->assertSame([ @@ -55,4 +98,4 @@ public function testCachesResultOfReduceFunction(): void 'TestType', ], $result); } -} \ No newline at end of file +} diff --git a/tests/Discovery/Cache/SnapshotClassFinderComputedCacheTest.php b/tests/Discovery/Cache/SnapshotClassFinderComputedCacheTest.php index bbf50389d1..51be822af0 100644 --- a/tests/Discovery/Cache/SnapshotClassFinderComputedCacheTest.php +++ b/tests/Discovery/Cache/SnapshotClassFinderComputedCacheTest.php @@ -1,25 +1,29 @@ setLogger(new ExceptionLogger()); @@ -27,15 +31,16 @@ public function testCachesIndividualEntries(): void $classFinderComputedCache = new SnapshotClassFinderComputedCache($cache); + $classList = [ + FooType::class, + FooExtendType::class, + TestType::class, + ]; [$result] = $classFinderComputedCache->compute( - new StaticClassFinder([ - FooType::class, - FooExtendType::class, - TestType::class, - ]), + new StaticClassFinder($classList), 'key', - fn (\ReflectionClass $reflection) => $reflection->getShortName(), - fn (array $entries) => [array_values($entries)], + static fn (ReflectionClass $reflection) => $reflection->getShortName(), + static fn (array $entries) => [array_values($entries)], ); $this->assertSame([ @@ -45,14 +50,10 @@ public function testCachesIndividualEntries(): void ], $result); [$result] = $classFinderComputedCache->compute( - new StaticClassFinder([ - FooType::class, - FooExtendType::class, - TestType::class, - ]), + new StaticClassFinder($classList), 'key', - fn (\ReflectionClass $reflection) => self::fail('Should not be called.'), - fn (array $entries) => [array_values($entries)], + static fn (ReflectionClass $reflection) => self::fail('Should not be called.'), + static fn (array $entries) => [array_values($entries)], ); $this->assertSame([ @@ -61,23 +62,19 @@ public function testCachesIndividualEntries(): void 'TestType', ], $result); - $this->touch((new \ReflectionClass(FooType::class))->getFileName()); + $this->touch((new ReflectionClass(FooType::class))->getFileName()); [$result] = $classFinderComputedCache->compute( - new StaticClassFinder([ - FooType::class, - TestType::class, - EnumType::class, - ]), + new StaticClassFinder($classList), 'key', - fn (\ReflectionClass $reflection) => $reflection->getShortName() . ' Modified', - fn (array $entries) => [array_values($entries)], + static fn (ReflectionClass $reflection) => $reflection->getShortName() . ' Modified', + static fn (array $entries) => [array_values($entries)], ); $this->assertSame([ 'FooType Modified', + 'FooExtendType', 'TestType', - 'EnumType Modified', ], $result); } @@ -86,4 +83,4 @@ private function touch(string $fileName): void touch($fileName, filemtime($fileName) + 1); clearstatcache(); } -} \ No newline at end of file +} diff --git a/tests/Discovery/KcsClassFinderTest.php b/tests/Discovery/KcsClassFinderTest.php index 589308236c..5698b0c0c2 100644 --- a/tests/Discovery/KcsClassFinderTest.php +++ b/tests/Discovery/KcsClassFinderTest.php @@ -20,9 +20,10 @@ class KcsClassFinderTest extends TestCase { public function testYieldsGivenClasses(): void { + $namespaces = 'TheCodingMachine\GraphQLite\Fixtures\Types'; $finder = new KcsClassFinder( - (new ComposerFinder()) - ->inNamespace('TheCodingMachine\GraphQLite\Fixtures\Types') + (new ComposerFinder())->inNamespace($namespaces), + md5($namespaces) ); $finderWithPath = $finder->withPathFilter(fn (string $path) => str_contains($path, 'FooExtendType.php')); @@ -50,4 +51,4 @@ private function assertFoundClasses(array $expectedClasses, ClassFinder $classFi $this->assertContainsOnlyInstancesOf(\ReflectionClass::class, $result); $this->assertEqualsCanonicalizing($expectedClasses, array_keys($result)); } -} \ No newline at end of file +} diff --git a/tests/Fixtures/ClassFinderCustomTypeMapper/Controllers/TestCustomMapperController.php b/tests/Fixtures/ClassFinderCustomTypeMapper/Controllers/TestCustomMapperController.php new file mode 100644 index 0000000000..9aaa339fe4 --- /dev/null +++ b/tests/Fixtures/ClassFinderCustomTypeMapper/Controllers/TestCustomMapperController.php @@ -0,0 +1,21 @@ +inNamespace('TheCodingMachine\\GraphQLite\\Fixtures'); - $finder->filter(static fn (ReflectionClass $class) => $class->getNamespaceName() === 'TheCodingMachine\\GraphQLite\\Fixtures'); // Fix for recursive:false + $finder->inNamespace($namespace); + $finder->filter(static fn (ReflectionClass $class) => $class->getNamespaceName() === $namespace); // Fix for recursive:false + $hash = md5($namespace); + $globControllerQueryProvider = new GlobControllerQueryProvider( $this->getFieldsBuilder(), $container, $this->getAnnotationReader(), - new KcsClassFinder($finder), - new HardClassFinderComputedCache(new Psr16Cache(new NullAdapter())) + new KcsClassFinder($finder, $hash), + new HardClassFinderComputedCache(new Psr16Cache(new NullAdapter())), ); $queries = $globControllerQueryProvider->getQueries(); diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php index 200247f56e..1b1aa796b9 100644 --- a/tests/Integration/IntegrationTestCase.php +++ b/tests/Integration/IntegrationTestCase.php @@ -95,12 +95,19 @@ public function createContainer(array $overloadedServices = []): ContainerInterf return new Schema($container->get(QueryProviderInterface::class), $container->get(RecursiveTypeMapperInterface::class), $container->get(TypeResolver::class), $container->get(RootTypeMapperInterface::class)); }, ClassFinder::class => function () { + $namespaces = [ + 'TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Types', + 'TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models', + 'TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers', + ]; + + $hash = md5(implode(',', $namespaces)); $composerFinder = new ComposerFinder(); - $composerFinder->inNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Types'); - $composerFinder->inNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models'); - $composerFinder->inNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers'); + foreach ($namespaces as $namespace) { + $composerFinder->inNamespace($namespace); + } - return new KcsClassFinder($composerFinder); + return new KcsClassFinder($composerFinder, $hash); }, ClassFinderComputedCache::class => function () { return new HardClassFinderComputedCache( diff --git a/tests/Mappers/ClassFinderTypeMapperTest.php b/tests/Mappers/ClassFinderTypeMapperTest.php index 1657c271c0..37f412a8ec 100644 --- a/tests/Mappers/ClassFinderTypeMapperTest.php +++ b/tests/Mappers/ClassFinderTypeMapperTest.php @@ -4,19 +4,23 @@ namespace TheCodingMachine\GraphQLite\Mappers; +use GraphQL\Error\DebugFlag; +use GraphQL\GraphQL; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type; use stdClass; use Symfony\Component\Cache\Adapter\ArrayAdapter; -use Symfony\Component\Cache\Adapter\NullAdapter; use Symfony\Component\Cache\Psr16Cache; use TheCodingMachine\GraphQLite\AbstractQueryProvider; use TheCodingMachine\GraphQLite\AnnotationReader; use TheCodingMachine\GraphQLite\Annotations\Exceptions\ClassNotFoundException; +use TheCodingMachine\GraphQLite\Containers\BasicAutoWiringContainer; +use TheCodingMachine\GraphQLite\Containers\EmptyContainer; use TheCodingMachine\GraphQLite\Containers\LazyContainer; use TheCodingMachine\GraphQLite\FailedResolvingInputType; use TheCodingMachine\GraphQLite\Fixtures\BadExtendType\BadExtendType; use TheCodingMachine\GraphQLite\Fixtures\BadExtendType2\BadExtendType2; +use TheCodingMachine\GraphQLite\Fixtures\ClassFinderCustomTypeMapper\Types\TestCustomMapperObject; use TheCodingMachine\GraphQLite\Fixtures\InheritedInputTypes\ChildTestFactory; use TheCodingMachine\GraphQLite\Fixtures\Integration\Types\FilterDecorator; use TheCodingMachine\GraphQLite\Fixtures\Mocks\MockResolvableInputObjectType; @@ -28,7 +32,9 @@ use TheCodingMachine\GraphQLite\Fixtures\Types\FooType; use TheCodingMachine\GraphQLite\Fixtures\Types\TestFactory; use TheCodingMachine\GraphQLite\GraphQLRuntimeException; +use TheCodingMachine\GraphQLite\Loggers\ExceptionLogger; use TheCodingMachine\GraphQLite\NamingStrategy; +use TheCodingMachine\GraphQLite\SchemaFactory; use TheCodingMachine\GraphQLite\Types\MutableObjectType; class ClassFinderTypeMapperTest extends AbstractQueryProvider @@ -401,4 +407,69 @@ public function testNonInstantiableInput(): void $this->expectExceptionMessage("Class 'TheCodingMachine\GraphQLite\Fixtures\NonInstantiableInput\AbstractFoo' annotated with @Input must be instantiable."); $mapper->mapClassToInputType(AbstractFoo::class); } + + public function testEndToEnd(): void + { + $container = new LazyContainer([ + FooType::class => static function () { + return new FooType(); + }, + FooExtendType::class => static function () { + return new FooExtendType(); + }, + ]); + + $typeGenerator = $this->getTypeGenerator(); + $inputTypeGenerator = $this->getInputTypeGenerator(); + + $classFinderComputedCache = $this->getClassFinderComputedCache(); + + // todo + $arrayAdapter = new ArrayAdapter(); + $arrayAdapter->setLogger(new ExceptionLogger()); + $schemaFactory = new SchemaFactory(new Psr16Cache($arrayAdapter), new BasicAutoWiringContainer(new EmptyContainer())); + $schemaFactory->addNamespace('TheCodingMachine\GraphQLite\Fixtures\ClassFinderTypeMapper\Types'); + $schemaFactory->addNamespace('TheCodingMachine\GraphQLite\Fixtures\ClassFinderTypeMapper\Controllers'); + $schemaFactory->addNamespace('TheCodingMachine\GraphQLite\Fixtures\ClassFinderCustomTypeMapper\Controllers'); + + $classFinder = $this->getClassFinder([ +// 'TheCodingMachine\GraphQLite\Fixtures\ClassFinderCustomTypeMapper\Controllers', + 'TheCodingMachine\GraphQLite\Fixtures\ClassFinderCustomTypeMapper\Types', + ]); + $mapper = new ClassFinderTypeMapper($classFinder, $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new AnnotationReader(), new NamingStrategy(), $this->getTypeMapper(), $classFinderComputedCache); + + // Register the class finder type mapper in your application using the SchemaFactory instance +// $schemaFactory->addTypeMapper($mapper); + + // ---- + $schemaFactory->addTypeMapperFactory(new StaticClassListTypeMapperFactory([TestCustomMapperObject::class])); + + $schema = $schemaFactory->createSchema(); + + $schema->validate(); + + $queryString = ' + query { + legacyObject { + foo + } + customMapperObject { + foo + } + } + '; + + $result = GraphQL::executeQuery( + $schema, + $queryString, + ); + + $this->assertSame([ + 'data' => [ + 'legacyObject' => ['foo' => 42], + 'customMapperObject' => ['foo' => 42], + ], +// 'legacyObject' => ['foo' => 42], + ], $result->toArray(DebugFlag::RETHROW_INTERNAL_EXCEPTIONS)); + } } From 183d3736d28f4fbf7a84176c751ba53c226f29d7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 Nov 2024 13:44:51 -0500 Subject: [PATCH 084/108] Bump codecov/codecov-action from 5.0.2 to 5.0.7 (#713) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.0.2 to 5.0.7. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v5.0.2...v5.0.7) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 69f024d6ce..9e09f213b6 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -84,7 +84,7 @@ jobs: path: "build" overwrite: true - - uses: codecov/codecov-action@v5.0.2 # upload the coverage to codecov + - uses: codecov/codecov-action@v5.0.7 # upload the coverage to codecov with: fail_ci_if_error: false # optional (default = false) - Need CODECOV_TOKEN # Do not upload in forks, and only on php8.3, latest deps From 04d592f4213c1e9d76c72028276800565ce8f2e9 Mon Sep 17 00:00:00 2001 From: Andrii Dembitskyi Date: Wed, 27 Nov 2024 01:32:30 +0200 Subject: [PATCH 085/108] =?UTF-8?q?:sparkles:=20[#708]=20Re-introduce=20fe?= =?UTF-8?q?ature=20about=20supporting=20target=20method=E2=80=A6=20(#709)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :sparkles: [#708] Re-introduce feature about supporting target method attributes for resolving parameter annotations * :package: Introduce runtime deprecation for deprecated functionality to hint about upgrade path --- src/AnnotationReader.php | 37 ++++++++++++++++++- .../Exceptions/InvalidParameterException.php | 5 +++ tests/AnnotationReaderTest.php | 29 +++++++++++++++ ...assWithTargetMethodParameterAnnotation.php | 21 +++++++++++ .../TargetMethodParameterAnnotation.php | 20 ++++++++++ 5 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tests/Fixtures/Annotations/ClassWithTargetMethodParameterAnnotation.php create mode 100644 tests/Fixtures/Annotations/TargetMethodParameterAnnotation.php diff --git a/src/AnnotationReader.php b/src/AnnotationReader.php index 95efef9421..39ebb6706c 100644 --- a/src/AnnotationReader.php +++ b/src/AnnotationReader.php @@ -24,14 +24,19 @@ use TheCodingMachine\GraphQLite\Annotations\Type; use TheCodingMachine\GraphQLite\Annotations\TypeInterface; +use function array_diff_key; use function array_filter; use function array_key_exists; use function array_map; use function array_merge; use function assert; use function count; +use function get_class; use function is_a; use function reset; +use function trigger_error; + +use const E_USER_DEPRECATED; class AnnotationReader { @@ -248,11 +253,41 @@ public function getParameterAnnotationsPerParameter(array $refParameters): array if (empty($refParameters)) { return []; } - $firstParam = reset($refParameters); + /** @var array> $parameterAnnotationsPerParameter */ + $parameterAnnotationsPerParameter = []; + + // resolve parameter annotations targeted to method + $firstParam = reset($refParameters); $method = $firstParam->getDeclaringFunction(); assert($method instanceof ReflectionMethod); + $parameterAnnotations = $this->getMethodAnnotations($method, ParameterAnnotationInterface::class); + foreach ($parameterAnnotations as $parameterAnnotation) { + trigger_error( + "Using '" . ParameterAnnotationInterface::class . "' over methods is deprecated. " . + "Found attribute '" . $parameterAnnotation::class . + "' over '" . $method->getDeclaringClass()->getName() . ':' . $method->getName() . "'. " . + "Please target annotation to the parameter '$" . $parameterAnnotation->getTarget() . "' instead", + E_USER_DEPRECATED, + ); + + $parameterAnnotationsPerParameter[$parameterAnnotation->getTarget()][] = $parameterAnnotation; + } + + // Let's check that the referenced parameters actually do exist: + $parametersByKey = []; + foreach ($refParameters as $refParameter) { + $parametersByKey[$refParameter->getName()] = true; + } + $diff = array_diff_key($parameterAnnotationsPerParameter, $parametersByKey); + if (count($diff) > 0) { + foreach ($diff as $parameterName => $parameterAnnotations) { + throw InvalidParameterException::parameterNotFound($parameterName, get_class($parameterAnnotations[0]), $method); + } + } + + // resolve parameter annotations targeted to parameter foreach ($refParameters as $refParameter) { $attributes = $refParameter->getAttributes(); $parameterAnnotationsPerParameter[$refParameter->getName()] = [...$parameterAnnotationsPerParameter[$refParameter->getName()] ?? diff --git a/src/Annotations/Exceptions/InvalidParameterException.php b/src/Annotations/Exceptions/InvalidParameterException.php index 86738a7ec3..2323695b32 100644 --- a/src/Annotations/Exceptions/InvalidParameterException.php +++ b/src/Annotations/Exceptions/InvalidParameterException.php @@ -11,6 +11,11 @@ class InvalidParameterException extends BadMethodCallException { + public static function parameterNotFound(string $parameter, string $annotationClass, ReflectionMethod $reflectionMethod): self + { + return new self(sprintf('Parameter "%s" declared in annotation "%s" of method "%s::%s()" does not exist.', $parameter, $annotationClass, $reflectionMethod->getDeclaringClass()->getName(), $reflectionMethod->getName())); + } + public static function parameterNotFoundFromSourceField(string $parameter, string $annotationClass, ReflectionMethod $reflectionMethod): self { return new self(sprintf('Could not find parameter "%s" declared in annotation "%s". This annotation is itself declared in a SourceField attribute targeting resolver "%s::%s()".', $parameter, $annotationClass, $reflectionMethod->getDeclaringClass()->getName(), $reflectionMethod->getName())); diff --git a/tests/AnnotationReaderTest.php b/tests/AnnotationReaderTest.php index 0ec8f2158e..f7493dead9 100644 --- a/tests/AnnotationReaderTest.php +++ b/tests/AnnotationReaderTest.php @@ -9,12 +9,15 @@ use ReflectionMethod; use TheCodingMachine\GraphQLite\Annotations\Autowire; use TheCodingMachine\GraphQLite\Annotations\Exceptions\ClassNotFoundException; +use TheCodingMachine\GraphQLite\Annotations\Exceptions\InvalidParameterException; use TheCodingMachine\GraphQLite\Annotations\Field; use TheCodingMachine\GraphQLite\Annotations\Security; use TheCodingMachine\GraphQLite\Annotations\Type; use TheCodingMachine\GraphQLite\Fixtures\Annotations\ClassWithInvalidClassAnnotation; use TheCodingMachine\GraphQLite\Fixtures\Annotations\ClassWithInvalidExtendTypeAnnotation; use TheCodingMachine\GraphQLite\Fixtures\Annotations\ClassWithInvalidTypeAnnotation; +use TheCodingMachine\GraphQLite\Fixtures\Annotations\ClassWithTargetMethodParameterAnnotation; +use TheCodingMachine\GraphQLite\Fixtures\Annotations\TargetMethodParameterAnnotation; use TheCodingMachine\GraphQLite\Fixtures\Attributes\TestType; class AnnotationReaderTest extends TestCase @@ -126,6 +129,32 @@ public function testPhp8AttributeParameterAnnotations(): void $this->assertInstanceOf(Autowire::class, $parameterAnnotations['dao']->getAnnotationByType(Autowire::class)); } + /** + * This functionality can be dropped with next major release (8.0) with added explicit deprecations before release. + */ + public function testPhp8AttributeParameterAnnotationsForTargetMethod(): void + { + $annotationReader = new AnnotationReader(); + + $parameterAnnotations = $annotationReader->getParameterAnnotationsPerParameter((new ReflectionMethod(ClassWithTargetMethodParameterAnnotation::class, 'method'))->getParameters()); + + $this->assertInstanceOf(TargetMethodParameterAnnotation::class, $parameterAnnotations['bar']->getAnnotationByType(TargetMethodParameterAnnotation::class)); + } + + /** + * This functionality can be dropped with next major release (8.0) with added explicit deprecations before release. + */ + public function testPhp8AttributeParameterAnnotationsForTargetMethodWithInvalidTargetParameter(): void + { + $annotationReader = new AnnotationReader(); + + $this->expectException(InvalidParameterException::class); + $this->expectExceptionMessage('Parameter "unexistent" declared in annotation "TheCodingMachine\GraphQLite\Fixtures\Annotations\TargetMethodParameterAnnotation" of method "TheCodingMachine\GraphQLite\Fixtures\Annotations\ClassWithTargetMethodParameterAnnotation::methodWithInvalidAnnotation()" does not exist.'); + + $annotationReader->getParameterAnnotationsPerParameter((new ReflectionMethod(ClassWithTargetMethodParameterAnnotation::class, 'methodWithInvalidAnnotation'))->getParameters()); + } + + /** @noinspection PhpUnusedPrivateMethodInspection Used in {@see testPhp8AttributeParameterAnnotations} */ private function method1( #[Autowire('myService')] $dao, diff --git a/tests/Fixtures/Annotations/ClassWithTargetMethodParameterAnnotation.php b/tests/Fixtures/Annotations/ClassWithTargetMethodParameterAnnotation.php new file mode 100644 index 0000000000..6830f2bbdf --- /dev/null +++ b/tests/Fixtures/Annotations/ClassWithTargetMethodParameterAnnotation.php @@ -0,0 +1,21 @@ +target; + } +} From 721c57a3717b222a3334a17fe34a821084e517a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Dec 2024 16:22:11 -0500 Subject: [PATCH 086/108] Bump JamesIves/github-pages-deploy-action from 4.6.9 to 4.7.1 (#714) Bumps [JamesIves/github-pages-deploy-action](https://github.com/jamesives/github-pages-deploy-action) from 4.6.9 to 4.7.1. - [Release notes](https://github.com/jamesives/github-pages-deploy-action/releases) - [Commits](https://github.com/jamesives/github-pages-deploy-action/compare/v4.6.9...v4.7.1) --- updated-dependencies: - dependency-name: JamesIves/github-pages-deploy-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/doc_generation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/doc_generation.yml b/.github/workflows/doc_generation.yml index 3b0b3ff239..3eb1271876 100644 --- a/.github/workflows/doc_generation.yml +++ b/.github/workflows/doc_generation.yml @@ -36,7 +36,7 @@ jobs: - name: "Deploy website" if: "${{ github.event_name == 'push' || github.event_name == 'release' }}" - uses: JamesIves/github-pages-deploy-action@v4.6.9 + uses: JamesIves/github-pages-deploy-action@v4.7.1 with: token: ${{ secrets.GITHUB_TOKEN }} branch: gh-pages # The branch the action should deploy to. From 816dd0863ff624fbb0439ed7a85303ab351bd2a6 Mon Sep 17 00:00:00 2001 From: Aleksander Mahnert Date: Fri, 6 Dec 2024 06:43:35 +0100 Subject: [PATCH 087/108] Update broken URL link to webonyx/graphql-php -> object-types (#715) * docs: fix broken URL link webonyx/graphql-php have changed the url in v15.0.0 * docs (versioned): fix broken URL link --- website/docs/custom-types.mdx | 2 +- website/versioned_docs/version-3.0/custom_types.mdx | 2 +- website/versioned_docs/version-4.0/custom_types.mdx | 2 +- website/versioned_docs/version-4.1/custom_types.mdx | 2 +- website/versioned_docs/version-4.2/custom-types.mdx | 2 +- website/versioned_docs/version-4.3/custom-types.mdx | 2 +- website/versioned_docs/version-5.0/custom-types.mdx | 2 +- website/versioned_docs/version-6.0/custom-types.mdx | 2 +- website/versioned_docs/version-6.1/custom-types.mdx | 2 +- website/versioned_docs/version-7.0.0/custom-types.mdx | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/website/docs/custom-types.mdx b/website/docs/custom-types.mdx index 527e91623c..3db2bec5bd 100644 --- a/website/docs/custom-types.mdx +++ b/website/docs/custom-types.mdx @@ -57,7 +57,7 @@ In order to create a custom output type, you need to: 1. Design a class that extends `GraphQL\Type\Definition\ObjectType`. 2. Register this class in the GraphQL schema. -You'll find more details on the [Webonyx documentation](https://webonyx.github.io/graphql-php/type-system/object-types/). +You'll find more details on the [Webonyx documentation](https://webonyx.github.io/graphql-php/type-definitions/object-types/). --- diff --git a/website/versioned_docs/version-3.0/custom_types.mdx b/website/versioned_docs/version-3.0/custom_types.mdx index 7def529fd8..9f52b8006d 100644 --- a/website/versioned_docs/version-3.0/custom_types.mdx +++ b/website/versioned_docs/version-3.0/custom_types.mdx @@ -109,7 +109,7 @@ In order to create a custom output type, you need to: 1. Design a class that extends `GraphQL\Type\Definition\ObjectType`. 2. Register this class in the GraphQL schema. -You'll find more details on the [Webonyx documentation](https://webonyx.github.io/graphql-php/type-system/object-types/). +You'll find more details on the [Webonyx documentation](https://webonyx.github.io/graphql-php/type-definitions/object-types/). --- diff --git a/website/versioned_docs/version-4.0/custom_types.mdx b/website/versioned_docs/version-4.0/custom_types.mdx index 727ed0adee..4c12b57fb2 100644 --- a/website/versioned_docs/version-4.0/custom_types.mdx +++ b/website/versioned_docs/version-4.0/custom_types.mdx @@ -63,7 +63,7 @@ In order to create a custom output type, you need to: 1. Design a class that extends `GraphQL\Type\Definition\ObjectType`. 2. Register this class in the GraphQL schema. -You'll find more details on the [Webonyx documentation](https://webonyx.github.io/graphql-php/type-system/object-types/). +You'll find more details on the [Webonyx documentation](https://webonyx.github.io/graphql-php/type-definitions/object-types/). --- diff --git a/website/versioned_docs/version-4.1/custom_types.mdx b/website/versioned_docs/version-4.1/custom_types.mdx index c5be458f4a..8ad3251e5f 100644 --- a/website/versioned_docs/version-4.1/custom_types.mdx +++ b/website/versioned_docs/version-4.1/custom_types.mdx @@ -110,7 +110,7 @@ In order to create a custom output type, you need to: 1. Design a class that extends `GraphQL\Type\Definition\ObjectType`. 2. Register this class in the GraphQL schema. -You'll find more details on the [Webonyx documentation](https://webonyx.github.io/graphql-php/type-system/object-types/). +You'll find more details on the [Webonyx documentation](https://webonyx.github.io/graphql-php/type-definitions/object-types/). --- diff --git a/website/versioned_docs/version-4.2/custom-types.mdx b/website/versioned_docs/version-4.2/custom-types.mdx index 34079ba2ab..ad9f7c2286 100644 --- a/website/versioned_docs/version-4.2/custom-types.mdx +++ b/website/versioned_docs/version-4.2/custom-types.mdx @@ -109,7 +109,7 @@ In order to create a custom output type, you need to: 1. Design a class that extends `GraphQL\Type\Definition\ObjectType`. 2. Register this class in the GraphQL schema. -You'll find more details on the [Webonyx documentation](https://webonyx.github.io/graphql-php/type-system/object-types/). +You'll find more details on the [Webonyx documentation](https://webonyx.github.io/graphql-php/type-definitions/object-types/). --- diff --git a/website/versioned_docs/version-4.3/custom-types.mdx b/website/versioned_docs/version-4.3/custom-types.mdx index 34079ba2ab..ad9f7c2286 100644 --- a/website/versioned_docs/version-4.3/custom-types.mdx +++ b/website/versioned_docs/version-4.3/custom-types.mdx @@ -109,7 +109,7 @@ In order to create a custom output type, you need to: 1. Design a class that extends `GraphQL\Type\Definition\ObjectType`. 2. Register this class in the GraphQL schema. -You'll find more details on the [Webonyx documentation](https://webonyx.github.io/graphql-php/type-system/object-types/). +You'll find more details on the [Webonyx documentation](https://webonyx.github.io/graphql-php/type-definitions/object-types/). --- diff --git a/website/versioned_docs/version-5.0/custom-types.mdx b/website/versioned_docs/version-5.0/custom-types.mdx index 34079ba2ab..ad9f7c2286 100644 --- a/website/versioned_docs/version-5.0/custom-types.mdx +++ b/website/versioned_docs/version-5.0/custom-types.mdx @@ -109,7 +109,7 @@ In order to create a custom output type, you need to: 1. Design a class that extends `GraphQL\Type\Definition\ObjectType`. 2. Register this class in the GraphQL schema. -You'll find more details on the [Webonyx documentation](https://webonyx.github.io/graphql-php/type-system/object-types/). +You'll find more details on the [Webonyx documentation](https://webonyx.github.io/graphql-php/type-definitions/object-types/). --- diff --git a/website/versioned_docs/version-6.0/custom-types.mdx b/website/versioned_docs/version-6.0/custom-types.mdx index 34079ba2ab..ad9f7c2286 100644 --- a/website/versioned_docs/version-6.0/custom-types.mdx +++ b/website/versioned_docs/version-6.0/custom-types.mdx @@ -109,7 +109,7 @@ In order to create a custom output type, you need to: 1. Design a class that extends `GraphQL\Type\Definition\ObjectType`. 2. Register this class in the GraphQL schema. -You'll find more details on the [Webonyx documentation](https://webonyx.github.io/graphql-php/type-system/object-types/). +You'll find more details on the [Webonyx documentation](https://webonyx.github.io/graphql-php/type-definitions/object-types/). --- diff --git a/website/versioned_docs/version-6.1/custom-types.mdx b/website/versioned_docs/version-6.1/custom-types.mdx index 3ca8eb1b3a..1b9d7c69c9 100644 --- a/website/versioned_docs/version-6.1/custom-types.mdx +++ b/website/versioned_docs/version-6.1/custom-types.mdx @@ -62,7 +62,7 @@ In order to create a custom output type, you need to: 1. Design a class that extends `GraphQL\Type\Definition\ObjectType`. 2. Register this class in the GraphQL schema. -You'll find more details on the [Webonyx documentation](https://webonyx.github.io/graphql-php/type-system/object-types/). +You'll find more details on the [Webonyx documentation](https://webonyx.github.io/graphql-php/type-definitions/object-types/). --- diff --git a/website/versioned_docs/version-7.0.0/custom-types.mdx b/website/versioned_docs/version-7.0.0/custom-types.mdx index 800ab2846c..40c3bacd5d 100644 --- a/website/versioned_docs/version-7.0.0/custom-types.mdx +++ b/website/versioned_docs/version-7.0.0/custom-types.mdx @@ -110,7 +110,7 @@ In order to create a custom output type, you need to: 1. Design a class that extends `GraphQL\Type\Definition\ObjectType`. 2. Register this class in the GraphQL schema. -You'll find more details on the [Webonyx documentation](https://webonyx.github.io/graphql-php/type-system/object-types/). +You'll find more details on the [Webonyx documentation](https://webonyx.github.io/graphql-php/type-definitions/object-types/). --- From d55db5e0f344552059badc1024d22bc358839e72 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Dec 2024 16:56:28 -0500 Subject: [PATCH 088/108] Bump codecov/codecov-action from 5.0.7 to 5.1.1 (#717) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.0.7 to 5.1.1. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v5.0.7...v5.1.1) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 9e09f213b6..15e2b3ca31 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -84,7 +84,7 @@ jobs: path: "build" overwrite: true - - uses: codecov/codecov-action@v5.0.7 # upload the coverage to codecov + - uses: codecov/codecov-action@v5.1.1 # upload the coverage to codecov with: fail_ci_if_error: false # optional (default = false) - Need CODECOV_TOKEN # Do not upload in forks, and only on php8.3, latest deps From f512f1c691cd83d1483ff8fe19294df8deaf5af8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Dec 2024 16:56:39 -0500 Subject: [PATCH 089/108] Bump JamesIves/github-pages-deploy-action from 4.7.1 to 4.7.2 (#716) Bumps [JamesIves/github-pages-deploy-action](https://github.com/jamesives/github-pages-deploy-action) from 4.7.1 to 4.7.2. - [Release notes](https://github.com/jamesives/github-pages-deploy-action/releases) - [Commits](https://github.com/jamesives/github-pages-deploy-action/compare/v4.7.1...v4.7.2) --- updated-dependencies: - dependency-name: JamesIves/github-pages-deploy-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/doc_generation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/doc_generation.yml b/.github/workflows/doc_generation.yml index 3eb1271876..e2a2abaf92 100644 --- a/.github/workflows/doc_generation.yml +++ b/.github/workflows/doc_generation.yml @@ -36,7 +36,7 @@ jobs: - name: "Deploy website" if: "${{ github.event_name == 'push' || github.event_name == 'release' }}" - uses: JamesIves/github-pages-deploy-action@v4.7.1 + uses: JamesIves/github-pages-deploy-action@v4.7.2 with: token: ${{ secrets.GITHUB_TOKEN }} branch: gh-pages # The branch the action should deploy to. From 6e626e28333db32f9aeaaa5e4d87e0c5e8bd6644 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Fri, 13 Dec 2024 20:47:46 -0500 Subject: [PATCH 090/108] Update docs to remove EnumType (#719) Docs are out of date for deprecated, `EnumType` - now just `Type`. --- website/versioned_docs/version-7.0.0/type-mapping.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/website/versioned_docs/version-7.0.0/type-mapping.mdx b/website/versioned_docs/version-7.0.0/type-mapping.mdx index 2051dd9559..53b7f43afb 100644 --- a/website/versioned_docs/version-7.0.0/type-mapping.mdx +++ b/website/versioned_docs/version-7.0.0/type-mapping.mdx @@ -559,7 +559,7 @@ query users($status: StatusEnum!) {} ``` By default, the name of the GraphQL enum type will be the name of the class. If you have a naming conflict (two classes -that live in different namespaces with the same class name), you can solve it using the `@EnumType` annotation: +that live in different namespaces with the same class name), you can solve it using the `name` argument of the `Type` attribute: ```php -use TheCodingMachine\GraphQLite\Annotations\EnumType; +use TheCodingMachine\GraphQLite\Annotations\Type; -#[EnumType(name: "UserStatus")] +#[Type(name: "UserStatus")] class StatusEnum extends Enum { // ... @@ -583,10 +583,10 @@ class StatusEnum extends Enum ```php -use TheCodingMachine\GraphQLite\Annotations\EnumType; +use TheCodingMachine\GraphQLite\Annotations\Type; /** - * @EnumType(name="UserStatus") + * @Type(name="UserStatus") */ class StatusEnum extends Enum { From 796e4cb3f96b94f89a5cabace0be5198ca6ae916 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Dec 2024 00:12:05 -0500 Subject: [PATCH 091/108] Update kcs/class-finder requirement from ^0.5.1 to ^0.5.1 || ^0.6.0 (#718) * Update kcs/class-finder requirement from ^0.5.1 to ^0.5.1 || ^0.6.0 Updates the requirements on [kcs/class-finder](https://github.com/alekitto/class-finder) to permit the latest version. - [Release notes](https://github.com/alekitto/class-finder/releases) - [Commits](https://github.com/alekitto/class-finder/compare/0.5.1...0.6.0) --- updated-dependencies: - dependency-name: kcs/class-finder dependency-type: direct:production ... Signed-off-by: dependabot[bot] * Upgrade to only ^0.6.0 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jacob Thomason --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 6063ab0c19..d4ef009482 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ "symfony/cache": "^4.3 || ^5 || ^6 || ^7", "symfony/expression-language": "^4 || ^5 || ^6 || ^7", "webonyx/graphql-php": "^v15.0", - "kcs/class-finder": "^0.5.1" + "kcs/class-finder": "^0.6.0" }, "require-dev": { "beberlei/porpaginas": "^1.2 || ^2.0", From 10c119762371e1be8c188ecd71f84de45b5647f9 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Wed, 18 Dec 2024 01:52:29 -0500 Subject: [PATCH 092/108] Updated docs for v8.0.0 (#720) --- website/README | 2 +- website/docs/CHANGELOG.md | 22 +- .../versioned_docs/version-8.0.0/CHANGELOG.md | 254 ++++++++++++ .../versioned_docs/version-8.0.0/README.mdx | 60 +++ .../version-8.0.0/annotations-reference.md | 320 +++++++++++++++ .../version-8.0.0/argument-resolving.md | 163 ++++++++ .../authentication-authorization.mdx | 159 ++++++++ .../automatic-persisted-queries.mdx | 61 +++ .../version-8.0.0/autowiring.mdx | 107 +++++ .../version-8.0.0/custom-types.mdx | 219 ++++++++++ .../doctrine-annotations-attributes.mdx | 102 +++++ .../version-8.0.0/error-handling.mdx | 144 +++++++ .../version-8.0.0/extend-input-type.mdx | 75 ++++ .../version-8.0.0/extend-type.mdx | 135 ++++++ .../external-type-declaration.mdx | 149 +++++++ .../version-8.0.0/field-middlewares.md | 135 ++++++ .../version-8.0.0/file-uploads.mdx | 60 +++ .../version-8.0.0/fine-grained-security.mdx | 179 ++++++++ .../version-8.0.0/getting-started.md | 16 + .../version-8.0.0/implementing-security.md | 57 +++ .../version-8.0.0/inheritance-interfaces.mdx | 162 ++++++++ .../version-8.0.0/input-types.mdx | 333 +++++++++++++++ .../versioned_docs/version-8.0.0/internals.md | 142 +++++++ .../laravel-package-advanced.mdx | 221 ++++++++++ .../version-8.0.0/laravel-package.md | 153 +++++++ .../versioned_docs/version-8.0.0/migrating.md | 54 +++ .../version-8.0.0/multiple-output-types.mdx | 105 +++++ .../version-8.0.0/mutations.mdx | 27 ++ .../version-8.0.0/operation-complexity.md | 223 ++++++++++ .../version-8.0.0/other-frameworks.mdx | 326 +++++++++++++++ .../version-8.0.0/pagination.mdx | 65 +++ .../version-8.0.0/prefetch-method.mdx | 108 +++++ .../versioned_docs/version-8.0.0/queries.mdx | 138 +++++++ .../version-8.0.0/query-plan.mdx | 70 ++++ .../versioned_docs/version-8.0.0/semver.md | 45 ++ .../version-8.0.0/subscriptions.mdx | 53 +++ .../version-8.0.0/symfony-bundle-advanced.mdx | 169 ++++++++ .../version-8.0.0/symfony-bundle.md | 121 ++++++ .../version-8.0.0/troubleshooting.md | 25 ++ .../version-8.0.0/type-mapping.mdx | 383 ++++++++++++++++++ .../universal-service-providers.md | 74 ++++ .../version-8.0.0/validation.mdx | 188 +++++++++ .../version-8.0.0-sidebars.json | 58 +++ website/versions.json | 1 + 44 files changed, 5661 insertions(+), 2 deletions(-) create mode 100644 website/versioned_docs/version-8.0.0/CHANGELOG.md create mode 100644 website/versioned_docs/version-8.0.0/README.mdx create mode 100644 website/versioned_docs/version-8.0.0/annotations-reference.md create mode 100644 website/versioned_docs/version-8.0.0/argument-resolving.md create mode 100644 website/versioned_docs/version-8.0.0/authentication-authorization.mdx create mode 100644 website/versioned_docs/version-8.0.0/automatic-persisted-queries.mdx create mode 100644 website/versioned_docs/version-8.0.0/autowiring.mdx create mode 100644 website/versioned_docs/version-8.0.0/custom-types.mdx create mode 100644 website/versioned_docs/version-8.0.0/doctrine-annotations-attributes.mdx create mode 100644 website/versioned_docs/version-8.0.0/error-handling.mdx create mode 100644 website/versioned_docs/version-8.0.0/extend-input-type.mdx create mode 100644 website/versioned_docs/version-8.0.0/extend-type.mdx create mode 100644 website/versioned_docs/version-8.0.0/external-type-declaration.mdx create mode 100644 website/versioned_docs/version-8.0.0/field-middlewares.md create mode 100644 website/versioned_docs/version-8.0.0/file-uploads.mdx create mode 100644 website/versioned_docs/version-8.0.0/fine-grained-security.mdx create mode 100644 website/versioned_docs/version-8.0.0/getting-started.md create mode 100644 website/versioned_docs/version-8.0.0/implementing-security.md create mode 100644 website/versioned_docs/version-8.0.0/inheritance-interfaces.mdx create mode 100644 website/versioned_docs/version-8.0.0/input-types.mdx create mode 100644 website/versioned_docs/version-8.0.0/internals.md create mode 100644 website/versioned_docs/version-8.0.0/laravel-package-advanced.mdx create mode 100644 website/versioned_docs/version-8.0.0/laravel-package.md create mode 100644 website/versioned_docs/version-8.0.0/migrating.md create mode 100644 website/versioned_docs/version-8.0.0/multiple-output-types.mdx create mode 100644 website/versioned_docs/version-8.0.0/mutations.mdx create mode 100644 website/versioned_docs/version-8.0.0/operation-complexity.md create mode 100644 website/versioned_docs/version-8.0.0/other-frameworks.mdx create mode 100644 website/versioned_docs/version-8.0.0/pagination.mdx create mode 100644 website/versioned_docs/version-8.0.0/prefetch-method.mdx create mode 100644 website/versioned_docs/version-8.0.0/queries.mdx create mode 100644 website/versioned_docs/version-8.0.0/query-plan.mdx create mode 100644 website/versioned_docs/version-8.0.0/semver.md create mode 100644 website/versioned_docs/version-8.0.0/subscriptions.mdx create mode 100644 website/versioned_docs/version-8.0.0/symfony-bundle-advanced.mdx create mode 100644 website/versioned_docs/version-8.0.0/symfony-bundle.md create mode 100644 website/versioned_docs/version-8.0.0/troubleshooting.md create mode 100644 website/versioned_docs/version-8.0.0/type-mapping.mdx create mode 100644 website/versioned_docs/version-8.0.0/universal-service-providers.md create mode 100644 website/versioned_docs/version-8.0.0/validation.mdx create mode 100644 website/versioned_sidebars/version-8.0.0-sidebars.json diff --git a/website/README b/website/README index 6702a98e28..e1ad431258 100644 --- a/website/README +++ b/website/README @@ -50,5 +50,5 @@ The [versioning section of the Docusaurus documentation](https://docusaurus.io/d ```bash $ docusaurus docs:version 1.1 ``` - *Be sure to include the X.X in the version, not just X.* + *Be sure to include the X.X.X in the version, not just X.* 3. Technically, you're done, just commit these changes and the CI workflow will deploy when merged into `master`. diff --git a/website/docs/CHANGELOG.md b/website/docs/CHANGELOG.md index bb83ceb4a0..30b292424f 100644 --- a/website/docs/CHANGELOG.md +++ b/website/docs/CHANGELOG.md @@ -4,6 +4,26 @@ title: Changelog sidebar_label: Changelog --- +## 8.0.0 + +### Breaking Changes + +- [#677 Drops support for Doctrine annotations](https://github.com/thecodingmachine/graphqlite/pull/677) @fogrye + +### Improvements + +- [#668 Adds working examples to docs](https://github.com/thecodingmachine/graphqlite/pull/668) @shish +- [#698 Performance optimizations and caching in development environments (`devMode()`)](https://github.com/thecodingmachine/graphqlite/pull/698) @oprypkhantc] + +### Bug Fixes + +- [#702 Fix prefetching for nested fields](https://github.com/thecodingmachine/graphqlite/pull/702) @sudevva + +### Minor Changes + +- [#695 Removes dependecy to unmaintained thecodingmachine/cache-utils dependency](https://github.com/thecodingmachine/graphqlite/pull/695) @xyng +- [#712 Caching improvements with use of multiple ClassFinders](https://github.com/thecodingmachine/graphqlite/pull/712) @andrew-demb + ## 7.1.0 ### Breaking Changes @@ -14,7 +34,7 @@ sidebar_label: Changelog - Removed `FactoryContext::get*TTL()` and `RootTypeMapperFactoryContext::get*TTL()` as GraphQLite no longer uses TTLs to invalidate caches - Removed `StaticClassListTypeMapper` in favor of `ClassFinderTypeMapper` used with `StaticClassFinder` - Renamed `GlobTypeMapper` to `ClassFinderTypeMapper` - - Renamed `SchemaFactory::setClassBoundCacheContractFactory()` to `SchemaFactory::setClassBoundCache()`, + - Renamed `SchemaFactory::setClassBoundCacheContractFactory()` to `SchemaFactory::setClassBoundCache()`, `FactoryContext::getClassBoundCacheContractFactory()` to `FactoryContext::getClassBoundCache()` and changed their signatures - Removed `RootTypeMapperFactoryContext::getTypeNamespaces()` in favor of `RootTypeMapperFactoryContext::getClassFinder()` diff --git a/website/versioned_docs/version-8.0.0/CHANGELOG.md b/website/versioned_docs/version-8.0.0/CHANGELOG.md new file mode 100644 index 0000000000..30b292424f --- /dev/null +++ b/website/versioned_docs/version-8.0.0/CHANGELOG.md @@ -0,0 +1,254 @@ +--- +id: changelog +title: Changelog +sidebar_label: Changelog +--- + +## 8.0.0 + +### Breaking Changes + +- [#677 Drops support for Doctrine annotations](https://github.com/thecodingmachine/graphqlite/pull/677) @fogrye + +### Improvements + +- [#668 Adds working examples to docs](https://github.com/thecodingmachine/graphqlite/pull/668) @shish +- [#698 Performance optimizations and caching in development environments (`devMode()`)](https://github.com/thecodingmachine/graphqlite/pull/698) @oprypkhantc] + +### Bug Fixes + +- [#702 Fix prefetching for nested fields](https://github.com/thecodingmachine/graphqlite/pull/702) @sudevva + +### Minor Changes + +- [#695 Removes dependecy to unmaintained thecodingmachine/cache-utils dependency](https://github.com/thecodingmachine/graphqlite/pull/695) @xyng +- [#712 Caching improvements with use of multiple ClassFinders](https://github.com/thecodingmachine/graphqlite/pull/712) @andrew-demb + +## 7.1.0 + +### Breaking Changes + +- #698 Removes some methods and classes, namely: + - Deprecated `SchemaFactory::addControllerNamespace()` and `SchemaFactory::addTypeNamespace()` in favor of `SchemaFactory::addNamespace()` + - Deprecated `SchemaFactory::setGlobTTL()` in favor of `SchemaFactory::devMode()` and `SchemaFactory::prodMode()` + - Removed `FactoryContext::get*TTL()` and `RootTypeMapperFactoryContext::get*TTL()` as GraphQLite no longer uses TTLs to invalidate caches + - Removed `StaticClassListTypeMapper` in favor of `ClassFinderTypeMapper` used with `StaticClassFinder` + - Renamed `GlobTypeMapper` to `ClassFinderTypeMapper` + - Renamed `SchemaFactory::setClassBoundCacheContractFactory()` to `SchemaFactory::setClassBoundCache()`, + `FactoryContext::getClassBoundCacheContractFactory()` to `FactoryContext::getClassBoundCache()` and changed their signatures + - Removed `RootTypeMapperFactoryContext::getTypeNamespaces()` in favor of `RootTypeMapperFactoryContext::getClassFinder()` + +### Improvements + +- #698 Performance optimizations and caching in development environments (`devMode()`). @oprypkhantc + +## 7.0.0 + +### Breaking Changes + +- #664 Replaces [thecodingmachine/class-explorer](https://github.com/thecodingmachine/class-explorer) with [kcs/class-finder](https://github.com/alekitto/class-finder) resulting in the `SchemaFactory::setClassNameMapper` being renamed to `SchemaFactory::setFinder`. This now expects an instance of `Kcs\ClassFinder\Finder` instead of `Kcs\ClassFinder\Finder\FinderInterface`. @fogrye + +### New Features + +- #649 Adds support for `subscription` operations. @oojacoboo +- #612 Automatic query complexity analysis. @oprypkhantc +- #611 Automatic persisted queries. @oprypkhantc + +### Improvements + +- #658 Improves on prefetching for nested fields. @grynchuk +- #646 Improves exception handling during schema parsing. @fogrye +- #636 Allows the use of middleware on construtor params/fields. @oprypkhantc +- #623 Improves support for description arguments on types/fields. @downace +- #628 Properly handles `@param` annotations for generics support on field annotated constructor arguments. @oojacoboo +- #584 Immutability improvements across the codebase. @oprypkhantc +- #588 Prefetch improvements. @oprpkhantc +- #606 Adds support for phpdoc descriptions and deprecation annotations on native enums. @mdoelker +- Thanks to @shish, @cvergne and @mshapovalov for updating the docs! + +### Minor Changes + +- #639 Added support for Symfony 7. @janatjak + + +## 6.2.3 + +Adds support for `Psr\Container` 1.1 with #601 + +## 6.2.2 + +This is a very simple release. We support Doctrine annotation 1.x and we've deprecated `SchemaFactory::setDoctrineAnnotationReader` in favor of native PHP attributes. + +## 6.2.1 + +- Added support for new `Void` return types, allowing use of `void` from operation resolvers. #574 +- Improvements with authorization middleware #571 +- Updated vendor dependencies: #580 #558 + +## 6.2.0 + +Lots of little nuggets in this release! We're now targeting PHP ^8.1 and have testing on 8.2. + +- Better support for union types and enums: #530, #535, #561, #570 +- Various bug and interface fixes: #532, #575, #564 +- GraphQL v15 required: #542 +- Lots of codebase improvements, more strict typing: #548 + +A special thanks to @rusted-love and @oprypkhantc for their contributions. + +## 6.1.0 + +A shoutout to @bladl for his work on this release, improving the code for better typing and PHP 8.0 syntax updates! + +### Breaking Changes + +- #518 PSR-11 support now requires version 2 +- #508 Due to some of the code improvements, additional typing has been added to some interfaces/classes. For instance, `RootTypeMapperInterface::toGraphQLOutputType` and `RootTypeMapperInterface::toGraphQLInputType` now have the following signatures: + +```php + /** + * @param (OutputType&GraphQLType)|null $subType + * + * @return OutputType&GraphQLType + */ + public function toGraphQLOutputType( + Type $type, + OutputType|null $subType, + ReflectionMethod|ReflectionProperty $reflector, + DocBlock $docBlockObj + ): OutputType; + + /** + * @param (InputType&GraphQLType)|null $subType + * + * @return InputType&GraphQLType + */ + public function toGraphQLInputType( + Type $type, + InputType|null $subType, + string $argumentName, + ReflectionMethod|ReflectionProperty $reflector, + DocBlock $docBlockObj + ): InputType; +``` + +### Improvements + +- #510 +- #508 + +## 5.0.0 + +### Dependencies + +- Upgraded to using version 14.9 of [webonyx/graphql-php](https://github.com/webonyx/graphql-php) + +## 4.3.0 + +### Breaking change + +- The method `setAnnotationCacheDir($directory)` has been removed from the `SchemaFactory`. The annotation + cache will use your `Psr\SimpleCache\CacheInterface` compliant cache handler set through the `SchemaFactory` + constructor. + +### Minor changes + +- Removed dependency for doctrine/cache and unified some of the cache layers following a PSR interface. +- Cleaned up some of the documentation in an attempt to get things accurate with versioned releases. + +## 4.2.0 + +### Breaking change + +The method signature for `toGraphQLOutputType` and `toGraphQLInputType` have been changed to the following: + +```php +/** + * @param \ReflectionMethod|\ReflectionProperty $reflector + */ +public function toGraphQLOutputType(Type $type, ?OutputType $subType, $reflector, DocBlock $docBlockObj): OutputType; + +/** + * @param \ReflectionMethod|\ReflectionProperty $reflector + */ +public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, $reflector, DocBlock $docBlockObj): InputType; +``` + +### New features + +- [@Input](annotations-reference.md#input-annotation) annotation is introduced as an alternative to `#[Factory]`. Now GraphQL input type can be created in the same manner as `#[Type]` in combination with `#[Field]` - [example](input-types.mdx#input-attribute). +- New attributes has been added to [@Field](annotations-reference.md#field-annotation) annotation: `for`, `inputType` and `description`. +- The following annotations now can be applied to class properties directly: `#[Field]`, `#[Logged]`, `#[Right]`, `@FailWith`, `@HideIfUnauthorized` and `#[Security]`. + +## 4.1.0 + +### Breaking change + +There is one breaking change introduced in the minor version (this was important to allow PHP 8 compatibility). + +- The **ecodev/graphql-upload** package (used to get support for file uploads in GraphQL input types) is now a "recommended" dependency only. + If you are using GraphQL file uploads, you need to add `ecodev/graphql-upload` to your `composer.json`. + +### New features + +- All annotations can now be accessed as PHP 8 attributes +- The `@deprecated` annotation in your PHP code translates into deprecated fields in your GraphQL schema +- You can now specify the GraphQL name of the Enum types you define +- Added the possibility to inject pure Webonyx objects in GraphQLite schema + +### Minor changes + +- Migrated from `zend/diactoros` to `laminas/diactoros` +- Making the annotation cache directory configurable + +### Miscellaneous + +- Migrated from Travis to Github actions + +## 4.0.0 + +This is a complete refactoring from 3.x. While existing annotations are kept compatible, the internals have completely +changed. + +### New features + +- You can directly [annotate a PHP interface with `#[Type]` to make it a GraphQL interface](inheritance-interfaces.mdx#mapping-interfaces) +- You can autowire services in resolvers, thanks to the new `@Autowire` annotation +- Added [user input validation](validation.mdx) (using the Symfony Validator or the Laravel validator or a custom `#[Assertion]` annotation +- Improved security handling: + - Unauthorized access to fields can now generate GraphQL errors (rather that schema errors in GraphQLite v3) + - Added fine-grained security using the `#[Security]` annotation. A field can now be [marked accessible or not depending on the context](fine-grained-security.mdx). + For instance, you can restrict access to the field "viewsCount" of the type `BlogPost` only for post that the current user wrote. + - You can now inject the current logged user in any query / mutation / field using the `#[InjectUser]` annotation +- Performance: + - You can inject the [Webonyx query plan in a parameter from a resolver](query-plan.mdx) + - You can use the [dataloader pattern to improve performance drastically via the "prefetchMethod" attribute](prefetch-method.mdx) +- Customizable error handling has been added: + - You can throw [many errors in one exception](error-handling.mdx#many-errors-for-one-exception) with `TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException` +- You can force input types using `@UseInputType(for="$id", inputType="ID!")` +- You can extend an input types (just like you could extend an output type in v3) using [the new `#[Decorate]` annotation](extend-input-type.mdx) +- In a factory, you can [exclude some optional parameters from the GraphQL schema](input-types#ignoring-some-parameters) + +Many extension points have been added + +- Added a "root type mapper" (useful to map scalar types to PHP types or to add custom annotations related to resolvers) +- Added ["field middlewares"](field-middlewares.md) (useful to add middleware that modify the way GraphQL fields are handled) +- Added a ["parameter type mapper"](argument-resolving.md) (useful to add customize parameter resolution or add custom annotations related to parameters) + +New framework specific features: + +### Symfony + +- The Symfony bundle now provides a "login" and a "logout" mutation (and also a "me" query) + +### Laravel + +- [Native integration with the Laravel paginator](laravel-package-advanced.mdx#support-for-pagination) has been added + +### Internals + +- The `FieldsBuilder` class has been split in many different services (`FieldsBuilder`, `TypeHandler`, and a + chain of *root type mappers*) +- The `FieldsBuilderFactory` class has been completely removed. +- Overall, there is not much in common internally between 4.x and 3.x. 4.x is much more flexible with many more hook points + than 3.x. Try it out! diff --git a/website/versioned_docs/version-8.0.0/README.mdx b/website/versioned_docs/version-8.0.0/README.mdx new file mode 100644 index 0000000000..0df34daa98 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/README.mdx @@ -0,0 +1,60 @@ +--- +id: index +title: GraphQLite +slug: / +sidebar_label: GraphQLite +--- + +

+ GraphQLite logo +

+ + +A PHP library that allows you to write your GraphQL queries in simple-to-write controllers. + +## Features + +* Create a complete GraphQL API by simply annotating your PHP classes +* Framework agnostic, but Symfony, Laravel and PSR-15 bindings available! +* Comes with batteries included: queries, mutations, subscriptions, mapping of arrays / iterators, +file uploads, security, validation, extendable types and more! + +## Basic example + +First, declare a query in your controller: + +```php +class ProductController +{ + #[Query] + public function product(string $id): Product + { + // Some code that looks for a product and returns it. + } +} +``` + +Then, annotate the `Product` class to declare what fields are exposed to the GraphQL API: + +```php +#[Type] +class Product +{ + #[Field] + public function getName(): string + { + return $this->name; + } + // ... +} +``` + +That's it, you're good to go! Query and enjoy! + +```graphql +{ + product(id: 42) { + name + } +} +``` diff --git a/website/versioned_docs/version-8.0.0/annotations-reference.md b/website/versioned_docs/version-8.0.0/annotations-reference.md new file mode 100644 index 0000000000..485b1f561c --- /dev/null +++ b/website/versioned_docs/version-8.0.0/annotations-reference.md @@ -0,0 +1,320 @@ +--- +id: annotations-reference +title: Attributes reference +sidebar_label: Attributes reference +--- + +Note: all annotations are available in PHP 8 attribute format (`#[Query]`), support of Doctrine annotation format was dropped. +See [Doctrine annotations vs PHP 8 attributes](doctrine-annotations-attributes.mdx) for more details. + +## #[Query] + +The `#[Query]` attribute is used to declare a GraphQL query. + +**Applies on**: controller methods. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +name | *no* | string | The name of the query. If skipped, the name of the method is used instead. +[outputType](custom-types.mdx) | *no* | string | Forces the GraphQL output type of a query. + +## #[Mutation] + +The `#[Mutation]` attribute is used to declare a GraphQL mutation. + +**Applies on**: controller methods. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +name | *no* | string | The name of the mutation. If skipped, the name of the method is used instead. +[outputType](custom-types.mdx) | *no* | string | Forces the GraphQL output type of a query. + +## #[Subscription] + +The `#[Subscription]` attribute is used to declare a GraphQL subscription. + +**Applies on**: controller methods. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +name | *no* | string | The name of the subscription. If skipped, the name of the method is used instead. +[outputType](custom-types.mdx) | *no* | string | Defines the GraphQL output type that will be sent for the subscription. + +## #[Type] + +The `#[Type]` attribute is used to declare a GraphQL object type. This is used with standard output +types, as well as enum types. For input types, use the [#[Input] attribute](#input-annotation) directly on the input type or a [#[Factory] attribute](#factory-annotation) to make/return an input type. + +**Applies on**: classes. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +class | *no* | string | The targeted class/enum for the actual type. If no "class" attribute is passed, the type applies to the current class/enum. The current class/enum is assumed to be an entity (not service). If the "class" attribute *is passed*, [the class/enum annotated with `#[Type]` becomes a service](external-type-declaration.mdx). +name | *no* | string | The name of the GraphQL type generated. If not passed, the name of the class is used. If the class ends with "Type", the "Type" suffix is removed +default | *no* | bool | Defaults to *true*. Whether the targeted PHP class should be mapped by default to this type. +external | *no* | bool | Whether this is an [external type declaration](external-type-declaration.mdx) or not. You usually do not need to use this attribute since this value defaults to true if a "class" attribute is set. This is only useful if you are declaring a type with no PHP class mapping using the "name" attribute. + +## #[ExtendType] + +The `#[ExtendType]` attribute is used to add fields to an existing GraphQL object type. + +**Applies on**: classes. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +class | see below | string | The targeted class. [The class annotated with `#[ExtendType]` is a service](extend-type.mdx). +name | see below | string | The targeted GraphQL output type. + +One and only one of "class" and "name" parameter can be passed at the same time. + +## #[Input] + +The `#[Input]` attribute is used to declare a GraphQL input type. + +**Applies on**: classes. + +Attribute | Compulsory | Type | Definition +---------------|------------|--------|-------- +name | *no* | string | The name of the GraphQL input type generated. If not passed, the name of the class with suffix "Input" is used. If the class ends with "Input", the "Input" suffix is not added. +description | *no* | string | Description of the input type in the documentation. If not passed, PHP doc comment is used. +default | *no* | bool | Name of the input type represented in your GraphQL schema. Defaults to `true` *only if* the name is not specified. If `name` is specified, this will default to `false`, so must also be included for `true` when `name` is used. +update | *no* | bool | Determines if the the input represents a partial update. When set to `true` all input fields will become optional and won't have default values thus won't be set on resolve if they are not specified in the query/mutation/subscription. This primarily applies to nullable fields. + +## #[Field] + +The `#[Field]` attribute is used to declare a GraphQL field. + +**Applies on**: methods or properties of classes annotated with `#[Type]`, `#[ExtendType]` or `#[Input]`. +When it's applied on private or protected property, public getter or/and setter method is expected in the class accordingly +whether it's used for output type or input type. For example if property name is `foo` then getter should be `getFoo()` or setter should be `setFoo($foo)`. Setter can be omitted if property related to the field is present in the constructor with the same name. + +Attribute | Compulsory | Type | Definition +------------------------------|------------|---------------|-------- +name | *no* | string | The name of the field. If skipped, the name of the method is used instead. +for | *no* | string, array | Forces the field to be used only for specific output or input type(s). By default field is used for all possible declared types. +description | *no* | string | Field description displayed in the GraphQL docs. If it's empty PHP doc comment is used instead. +[outputType](custom-types.mdx) | *no* | string | Forces the GraphQL output type of a query. +[inputType](input-types.mdx) | *no* | string | Forces the GraphQL input type of a query. + +## #[SourceField] + +The `#[SourceField]` attribute is used to declare a GraphQL field. + +**Applies on**: classes annotated with `#[Type]` or `#[ExtendType]`. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +name | *yes* | string | The name of the field. +[outputType](custom-types.mdx) | *no* | string | Forces the GraphQL output type of the field. Otherwise, return type is used. +phpType | *no* | string | The PHP type of the field (as you would write it in a Docblock) +description | *no* | string | Field description displayed in the GraphQL docs. If it's empty PHP doc comment of the method in the source class is used instead. +sourceName | *no* | string | The name of the property in the source class. If not set, the `name` will be used to get property value. +annotations | *no* | array\ | A set of annotations that apply to this field. You would typically used a "#[Logged]" or "#[Right]" attribute as class here. + +**Note**: `outputType` and `phpType` are mutually exclusive. + +## #[MagicField] + +The `#[MagicField]` attribute is used to declare a GraphQL field that originates from a PHP magic property (using `__get` magic method). + +**Applies on**: classes annotated with `#[Type]` or `#[ExtendType]`. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +name | *yes* | string | The name of the field. +[outputType](custom-types.mdx) | *no*(*) | string | The GraphQL output type of the field. +phpType | *no*(*) | string | The PHP type of the field (as you would write it in a Docblock) +description | *no* | string | Field description displayed in the GraphQL docs. If not set, no description will be shown. +sourceName | *no* | string | The name of the property in the source class. If not set, the `name` will be used to get property value. +annotations | *no* | array\ | A set of annotations that apply to this field. You would typically used a "#[Logged]" or "#[Right]" attribute as class here. + +(*) **Note**: `outputType` and `phpType` are mutually exclusive. You MUST provide one of them. + +## #[Prefetch] + +Marks field parameter to be used for [prefetching](prefetch-method.mdx). + +**Applies on**: parameters of methods annotated with `#[Query]`, `#[Mutation]` or `#[Field]`. + +Attribute | Compulsory | Type | Definition +------------------------------|------------|----------|-------- +callable | *no* | callable | Name of the prefetch method (in same class) or a full callable, either a static method or regular service from the container + + +## #[Logged] + +The `#[Logged]` attribute is used to declare a Query/Mutation/Field is only visible to logged users. + +**Applies on**: methods or properties annotated with `#[Query]`, `#[Mutation]` or `#[Field]`. + +This attribute allows no arguments. + +## #[Right] + +The `#[Right]` attribute is used to declare a Query/Mutation/Field is only visible to users with a specific right. + +**Applies on**: methods or properties annotated with `#[Query]`, `#[Mutation]` or `#[Field]`. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +name | *yes* | string | The name of the right. + +## #[FailWith] + +The `#[FailWith]` attribute is used to declare a default value to return in the user is not authorized to see a specific +query/mutation/subscription/field (according to the `#[Logged]` and `#[Right]` attributes). + +**Applies on**: methods or properties annotated with `#[Query]`, `#[Mutation]` or `#[Field]` and one of `#[Logged]` or `#[Right]` attributes. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +value | *yes* | mixed | The value to return if the user is not authorized. + +## #[HideIfUnauthorized] + +
This attribute only works when a Schema is used to handle exactly one use request. +If you serve your GraphQL API from long-running standalone servers (like Laravel Octane, Swoole, RoadRunner etc) and +share the same Schema instance between multiple requests, please avoid using #[HideIfUnauthorized].
+ +The `#[HideIfUnauthorized]` attribute is used to completely hide the query/mutation/subscription/field if the user is not authorized +to access it (according to the `#[Logged]` and `#[Right]` attributes). + +**Applies on**: methods or properties annotated with `#[Query]`, `#[Mutation]` or `#[Field]` and one of `#[Logged]` or `#[Right]` attributes. + +`#[HideIfUnauthorized]` and `#[FailWith]` are mutually exclusive. + +## #[InjectUser] + +Use the `#[InjectUser]` attribute to inject an instance of the current user logged in into a parameter of your +query/mutation/subscription/field. + +See [the authentication and authorization page](authentication-authorization.mdx) for more details. + +**Applies on**: methods annotated with `#[Query]`, `#[Mutation]` or `#[Field]`. + +Attribute | Compulsory | Type | Definition +---------------|------------|--------|-------- +*for* | *yes* | string | The name of the PHP parameter + +## #[Security] + +The `#[Security]` attribute can be used to check fin-grained access rights. +It is very flexible: it allows you to pass an expression that can contains custom logic. + +See [the fine grained security page](fine-grained-security.mdx) for more details. + +**Applies on**: methods or properties annotated with `#[Query]`, `#[Mutation]` or `#[Field]`. + +Attribute | Compulsory | Type | Definition +---------------|------------|--------|-------- +*default* | *yes* | string | The security expression + +## #[Factory] + +The `#[Factory]` attribute is used to declare a factory that turns GraphQL input types into objects. + +**Applies on**: methods from classes in the "types" namespace. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +name | *no* | string | The name of the input type. If skipped, the name of class returned by the factory is used instead. +default | *no* | bool | If `true`, this factory will be used by default for its PHP return type. If set to `false`, you must explicitly [reference this factory using the `#[Parameter]` attribute](input-types.mdx#declaring-several-input-types-for-the-same-php-class). + +## #[UseInputType] + +Used to override the GraphQL input type of a PHP parameter. + +**Applies on**: methods annotated with `#[Query]`, `#[Mutation]` or `#[Field]` attribute. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +*for* | *yes* | string | The name of the PHP parameter +*inputType* | *yes* | string | The GraphQL input type to force for this input field + +## #[Decorate] + +The `#[Decorate]` attribute is used [to extend/modify/decorate an input type declared with the `#[Factory]` attribute](extend-input-type.mdx). + +**Applies on**: methods from classes in the "types" namespace. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +name | *yes* | string | The GraphQL input type name extended by this decorator. + +## #[Autowire] + +[Resolves a PHP parameter from the container](autowiring.mdx). + +Useful to inject services directly into `#[Field]` method arguments. + +**Applies on**: methods annotated with `#[Query]`, `#[Mutation]` or `#[Field]` attribute. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +*for* | *yes* | string | The name of the PHP parameter +*identifier* | *no* | string | The identifier of the service to fetch. This is optional. Please avoid using this attribute as this leads to a "service locator" anti-pattern. + +## #[HideParameter] + +Removes [an argument from the GraphQL schema](input-types.mdx#ignoring-some-parameters). + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +*for* | *yes* | string | The name of the PHP parameter to hide + +## #[Cost] + +Sets complexity and multipliers on fields for [automatic query complexity](operation-complexity.md#static-request-analysis). + +Attribute | Compulsory | Type | Definition +--------------------|------------|-----------------|----------------------------------------------------------------- +*complexity* | *no* | int | Complexity for that field +*multipliers* | *no* | array\ | Names of fields by value of which complexity will be multiplied +*defaultMultiplier* | *no* | int | Default multiplier value if all multipliers are missing/null + +## #[Validate] + +
This attribute is only available in the GraphQLite Laravel package
+ +[Validates a user input in Laravel](laravel-package-advanced.mdx). + +**Applies on**: methods annotated with `#[Query]`, `#[Mutation]`, `#[Field]`, `#[Factory]` or `#[Decorator]` attribute. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +*for* | *yes* | string | The name of the PHP parameter +*rule* | *yes | string | Laravel validation rules + +Sample: + +```php +#[Validate(for: "$email", rule: "email|unique:users")] +``` + +## #[Assertion] + +[Validates a user input](validation.mdx). + +The `#[Assertion]` attribute is available in the *thecodingmachine/graphqlite-symfony-validator-bridge* third party package. +It is available out of the box if you use the Symfony bundle. + +**Applies on**: methods annotated with `#[Query]`, `#[Mutation]`, `#[Field]`, `#[Factory]` or `#[Decorator]` attribute. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +*for* | *yes* | string | The name of the PHP parameter +*constraint* | *yes | annotation | One (or many) Symfony validation attributes. + +## ~~@EnumType~~ + +*Deprecated: Use [PHP 8.1's native Enums](https://www.php.net/manual/en/language.types.enumerations.php) instead with a [#[Type]](#type-annotation).* + +The `@EnumType` annotation is used to change the name of a "Enum" type. +Note that if you do not want to change the name, the annotation is optionnal. Any object extending `MyCLabs\Enum\Enum` +is automatically mapped to a GraphQL enum type. + +**Applies on**: classes extending the `MyCLabs\Enum\Enum` base class. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +name | *no* | string | The name of the enum type (in the GraphQL schema) diff --git a/website/versioned_docs/version-8.0.0/argument-resolving.md b/website/versioned_docs/version-8.0.0/argument-resolving.md new file mode 100644 index 0000000000..8125964dae --- /dev/null +++ b/website/versioned_docs/version-8.0.0/argument-resolving.md @@ -0,0 +1,163 @@ +--- +id: argument-resolving +title: Extending argument resolving +sidebar_label: Custom argument resolving +--- +Available in GraphQLite 4.0+ + +Using a **parameter middleware**, you can hook into the argument resolution of field/query/mutation/factory. + +
Use a parameter middleware if you want to alter the way arguments are injected in a method +or if you want to alter the way input types are imported (for instance if you want to add a validation step)
+ +As an example, GraphQLite uses *parameter middlewares* internally to: + +- Inject the Webonyx GraphQL resolution object when you type-hint on the `ResolveInfo` object. For instance: + + ```php + /** + * @return Product[] + */ + #[Query] + public function products(ResolveInfo $info): array + ``` + + In the query above, the `$info` argument is filled with the Webonyx `ResolveInfo` class thanks to the + [`ResolveInfoParameterHandler parameter middleware`](https://github.com/thecodingmachine/graphqlite/blob/master/src/Mappers/Parameters/ResolveInfoParameterHandler.php) +- Inject a service from the container when you use the `#[Autowire]` attribute +- Perform validation with the `#[Validate]` attribute (in Laravel package) + + + +**Parameter middlewares** + + + +Each middleware is passed number of objects describing the parameter: + +- a PHP `ReflectionParameter` object representing the parameter being manipulated +- a `phpDocumentor\Reflection\DocBlock` instance (useful to analyze the `@param` comment if any) +- a `phpDocumentor\Reflection\Type` instance (useful to analyze the type if the argument) +- a `TheCodingMachine\GraphQLite\Annotations\ParameterAnnotations` instance. This is a collection of all custom annotations that apply to this specific argument (more on that later) +- a `$next` handler to pass the argument resolving to the next middleware. + +Parameter resolution is done in 2 passes. + +On the first pass, middlewares are traversed. They must return a `TheCodingMachine\GraphQLite\Parameters\ParameterInterface` (an object that does the actual resolving). + +```php +interface ParameterMiddlewareInterface +{ + public function mapParameter(ReflectionParameter $parameter, DocBlock $docBlock, ?Type $paramTagType, ParameterAnnotations $parameterAnnotations, ParameterHandlerInterface $next): ParameterInterface; +} +``` + +Then, resolution actually happen by executing the resolver (this is the second pass). + +## Attributes parsing + +If you plan to use attributes while resolving arguments, your attribute class should extend the [`ParameterAnnotationInterface`](https://github.com/thecodingmachine/graphqlite/blob/master/src/Annotations/ParameterAnnotationInterface.php) + +For instance, if we want GraphQLite to inject a service in an argument, we can use `#[Autowire]`. + +We only need to put declare the annotation can target parameters: `#[Attribute(Attribute::TARGET_PARAMETER)]`. + +The class looks like this: + +```php +use Attribute; + +/** + * Use this attribute to autowire a service from the container into a given parameter of a field/query/mutation. + * + */ +#[Attribute(Attribute::TARGET_PARAMETER)] +class Autowire implements ParameterAnnotationInterface +{ + /** + * @var string + */ + public $for; + + /** + * The getTarget method must return the name of the argument + */ + public function getTarget(): string + { + return $this->for; + } +} +``` + +## Writing the parameter middleware + +The middleware purpose is to analyze a parameter and decide whether or not it can handle it. + +```php title="Parameter middleware class" +class ContainerParameterHandler implements ParameterMiddlewareInterface +{ + /** @var ContainerInterface */ + private $container; + + public function __construct(ContainerInterface $container) + { + $this->container = $container; + } + + public function mapParameter(ReflectionParameter $parameter, DocBlock $docBlock, ?Type $paramTagType, ParameterAnnotations $parameterAnnotations, ParameterHandlerInterface $next): ParameterInterface + { + // The $parameterAnnotations object can be used to fetch any annotation implementing ParameterAnnotationInterface + $autowire = $parameterAnnotations->getAnnotationByType(Autowire::class); + + if ($autowire === null) { + // If there are no annotation, this middleware cannot handle the parameter. Let's ask + // the next middleware in the chain (using the $next object) + return $next->mapParameter($parameter, $docBlock, $paramTagType, $parameterAnnotations); + } + + // We found a @Autowire annotation, let's return a parameter resolver. + return new ContainerParameter($this->container, $parameter->getType()); + } +} +``` + +The last step is to write the actual parameter resolver. + +```php title="Parameter resolver class" +/** + * A parameter filled from the container. + */ +class ContainerParameter implements ParameterInterface +{ + /** @var ContainerInterface */ + private $container; + /** @var string */ + private $identifier; + + public function __construct(ContainerInterface $container, string $identifier) + { + $this->container = $container; + $this->identifier = $identifier; + } + + /** + * The "resolver" returns the actual value that will be fed to the function. + */ + public function resolve(?object $source, array $args, $context, ResolveInfo $info) + { + return $this->container->get($this->identifier); + } +} +``` + +## Registering a parameter middleware + +The last step is to register the parameter middleware we just wrote: + +You can register your own parameter middlewares using the `SchemaFactory::addParameterMiddleware()` method. + +```php +$schemaFactory->addParameterMiddleware(new ContainerParameterHandler($container)); +``` + +If you are using the Symfony bundle, you can tag the service as "graphql.parameter_middleware". diff --git a/website/versioned_docs/version-8.0.0/authentication-authorization.mdx b/website/versioned_docs/version-8.0.0/authentication-authorization.mdx new file mode 100644 index 0000000000..9842e2a3d3 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/authentication-authorization.mdx @@ -0,0 +1,159 @@ +--- +id: authentication-authorization +title: Authentication and authorization +sidebar_label: Authentication and authorization +--- + +You might not want to expose your GraphQL API to anyone. Or you might want to keep some +queries/mutations/subscriptions or fields reserved to some users. + +GraphQLite offers some control over what a user can do with your API. You can restrict access to +resources: + +- based on authentication using the [`#[Logged]` attribute](#logged-and-right-annotations) (restrict access to logged users) +- based on authorization using the [`#[Right]` attribute](#logged-and-right-annotations) (restrict access to logged users with certain rights). +- based on fine-grained authorization using the [`#[Security]` attribute](fine-grained-security.mdx) (restrict access for some given resources to some users). + +
+ GraphQLite does not have its own security mechanism. + Unless you're using our Symfony Bundle or our Laravel package, it is up to you to connect this feature to your framework's security mechanism.
+ See Connecting GraphQLite to your framework's security module. +
+ +## `#[Logged]` and `#[Right]` attributes + +GraphQLite exposes two attributes (`#[Logged]` and `#[Right]`) that you can use to restrict access to a resource. + +```php +namespace App\Controller; + +use TheCodingMachine\GraphQLite\Annotations\Query; +use TheCodingMachine\GraphQLite\Annotations\Logged; +use TheCodingMachine\GraphQLite\Annotations\Right; + +class UserController +{ + /** + * @return User[] + */ + #[Query] + #[Logged] + #[Right("CAN_VIEW_USER_LIST")] + public function users(int $limit, int $offset): array + { + // ... + } +} +``` + +In the example above, the query `users` will only be available if the user making the query is logged AND if he +has the `CAN_VIEW_USER_LIST` right. + +`#[Logged]` and `#[Right]` attributes can be used next to: + +* `#[Query]` attributes +* `#[Mutation]` attributes +* `#[Field]` attributes + +
+ By default, if a user tries to access an unauthorized query/mutation/subscription/field, an error is + raised and the query fails. +
+ +## Not throwing errors + +If you do not want an error to be thrown when a user attempts to query a field/query/mutation/subscription +they have no access to, you can use the `#[FailWith]` attribute. + +The `#[FailWith]` attribute contains the value that will be returned for users with insufficient rights. + +```php +class UserController +{ + /** + * If a user is not logged or if the user has not the right "CAN_VIEW_USER_LIST", + * the value returned will be "null". + * + * @return User[] + */ + #[Query] + #[Logged] + #[Right("CAN_VIEW_USER_LIST")] + #[FailWith(value: null)] + public function users(int $limit, int $offset): array + { + // ... + } +} +``` + +## Injecting the current user as a parameter + +Use the `#[InjectUser]` attribute to get an instance of the current user logged in. + +```php +namespace App\Controller; + +use TheCodingMachine\GraphQLite\Annotations\Query; +use TheCodingMachine\GraphQLite\Annotations\InjectUser; + +class ProductController +{ + /** + * @return Product + */ + #[Query] + public function product( + int $id, + #[InjectUser] + User $user + ): Product + { + // ... + } +} +``` + +The `#[InjectUser]` attribute can be used next to: + +* `#[Query]` attributes +* `#[Mutation]` attributes +* `#[Field]` attributes + +The object injected as the current user depends on your framework. It is in fact the object returned by the +["authentication service" configured in GraphQLite](implementing-security.md). If user is not authenticated and +parameter's type is not nullable, an authorization exception is thrown, similar to `#[Logged]` attribute. + +## Hiding fields / queries / mutations / subscriptions + +By default, a user analysing the GraphQL schema can see all queries/mutations/subscriptions/types available. +Some will be available to him and some won't. + +If you want to add an extra level of security (or if you want your schema to be kept secret to unauthorized users), +you can use the `#[HideIfUnauthorized]` attribute. Beware of [it's limitations](annotations-reference.md). + +```php +class UserController +{ + /** + * If a user is not logged or if the user has not the right "CAN_VIEW_USER_LIST", + * the schema will NOT contain the "users" query at all (so trying to call the + * "users" query will result in a GraphQL "query not found" error. + * + * @return User[] + */ + #[Query] + #[Logged] + #[Right("CAN_VIEW_USER_LIST")] + #[HideIfUnauthorized] + public function users(int $limit, int $offset): array + { + // ... + } +} +``` + +While this is the most secured mode, it can have drawbacks when working with development tools +(you need to be logged as admin to fetch the complete schema). + +
The "HideIfUnauthorized" mode was the default mode in GraphQLite 3 and is optional from GraphQLite 4+.
diff --git a/website/versioned_docs/version-8.0.0/automatic-persisted-queries.mdx b/website/versioned_docs/version-8.0.0/automatic-persisted-queries.mdx new file mode 100644 index 0000000000..f505a869f4 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/automatic-persisted-queries.mdx @@ -0,0 +1,61 @@ +--- +id: automatic-persisted-queries +title: Automatic persisted queries +sidebar_label: Automatic persisted queries +--- + +## The problem + +Clients send queries to GraphQLite as HTTP requests that include the GraphQL string of the query to execute. +Depending on your graph's schema, the size of a valid query string might be arbitrarily large. +As query strings become larger, increased latency and network usage can noticeably degrade client performance. + +To combat this, GraphQL servers use a technique called "persisted queries". The basic idea is instead of +sending the whole query string, clients only send it's unique identifier. The server then finds the actual +query string by given identifier and use that as if the client sent the whole query in the first place. +That helps improve GraphQL network performance with zero build-time configuration by sending smaller GraphQL HTTP requests. +A smaller request payload reduces bandwidth utilization and speeds up GraphQL Client loading times. + +## Apollo APQ + +[Automatic persisted queries (APQ) is technique created by Apollo](https://www.apollographql.com/docs/apollo-server/performance/apq/) +and is aimed to implement a simple automatic way of persisting queries. Queries are cached on the server side, +along with its unique identifier (always its SHA-256 hash). Clients can send this identifier instead of the +corresponding query string, thus reducing request sizes dramatically (response sizes are unaffected). + +To persist a query string, GraphQLite server must first receive it from a requesting client. +Consequently, each unique query string must be sent to Apollo Server at least once. +After any client sends a query string to persist, every client that executes that query can then benefit from APQ. + +```mermaid +sequenceDiagram; + Client app->>GraphQL Server: Sends SHA-256 hash of query string to execute + Note over GraphQL Server: Fails to find persisted query string + GraphQL Server->>Client app: Responds with error + Client app->>GraphQL Server: Sends both query string AND hash + Note over GraphQL Server: Persists query string and hash + GraphQL Server->>Client app: Executes query and returns result + Note over Client app: Time passes + Client app->>GraphQL Server: Sends SHA-256 hash of query string to execute + Note over GraphQL Server: Finds persisted query string + GraphQL Server->>Client app: Executes query and returns result +``` + +Persisted queries are especially effective when clients send queries as GET requests. +This enables clients to take advantage of the browser cache and integrate with a CDN. + +Because query identifiers are deterministic hashes, clients can generate them at runtime. No additional build steps are required. + +## Setup + +To use Automatic persisted queries with GraphQLite, you may use +`useAutomaticPersistedQueries` method when building your PSR-15 middleware: + +```php +$builder = new Psr15GraphQLMiddlewareBuilder($schema); + +// You need to provide a PSR compatible cache and a TTL for queries. The best cache would be some kind +// of in-memory cache with a limit on number of entries to make sure your cache can't be maliciously spammed with queries. +$builder->useAutomaticPersistedQueries($cache, new DateInterval('PT1H')); +``` + diff --git a/website/versioned_docs/version-8.0.0/autowiring.mdx b/website/versioned_docs/version-8.0.0/autowiring.mdx new file mode 100644 index 0000000000..825241afe0 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/autowiring.mdx @@ -0,0 +1,107 @@ +--- +id: autowiring +title: Autowiring services +sidebar_label: Autowiring services +--- + +GraphQLite can automatically inject services in your fields/queries/mutations signatures. + +Some of your fields may be computed. In order to compute these fields, you might need to call a service. + +Most of the time, your `#[Type]` attribute will be put on a model. And models do not have access to services. +Hopefully, if you add a type-hinted service in your field's declaration, GraphQLite will automatically fill it with +the service instance. + +## Sample + +Let's assume you are running an international store. You have a `Product` class. Each product has many names (depending +on the language of the user). + +```php +namespace App\Entities; + +use TheCodingMachine\GraphQLite\Annotations\Autowire; +use TheCodingMachine\GraphQLite\Annotations\Field; +use TheCodingMachine\GraphQLite\Annotations\Type; + +use Symfony\Component\Translation\TranslatorInterface; + +#[Type] +class Product +{ + // ... + + #[Field] + public function getName( + #[Autowire] + TranslatorInterface $translator + ): string + { + return $translator->trans('product_name_'.$this->id); + } +} +``` + +When GraphQLite queries the name, it will automatically fetch the translator service. + +
As with most autowiring solutions, GraphQLite assumes that the service identifier +in the container is the fully qualified class name of the type-hint. So in the example above, GraphQLite will +look for a service whose name is Symfony\Component\Translation\TranslatorInterface.
+ +## Best practices + +It is a good idea to refrain from type-hinting on concrete implementations. +Most often, your field declaration will be in your model. If you add a type-hint on a service, you are binding your domain +with a particular service implementation. This makes your code tightly coupled and less testable. + +
+Please don't do that: + +

+    #[Field]
+    public function getName(#[Autowire] MyTranslator $translator): string
+    {
+        // Your domain is suddenly tightly coupled to the MyTranslator class.
+    }
+
+
+ +Instead, be sure to type-hint against an interface. + +
+Do this instead: + +

+    #[Field]
+    public function getName(#[Autowire] TranslatorInterface $translator): string
+    {
+        // Good. You can switch translator implementation any time.
+    }
+
+
+ +By type-hinting against an interface, your code remains testable and is decoupled from the service implementation. + +## Fetching a service by name (discouraged!) + +Optionally, you can specify the identifier of the service you want to fetch from the controller: + +```php +#[Autowire(identifier: "translator")] +``` + +
While GraphQLite offers the possibility to specify the name of the service to be +autowired, we would like to emphasize that this is highly discouraged. Hard-coding a container +identifier in the code of your class is akin to using the "service locator" pattern, which is known to be an +anti-pattern. Please refrain from doing this as much as possible.
+ +## Alternative solution + +You may find yourself uncomfortable with the autowiring mechanism of GraphQLite. For instance maybe: + +- Your service identifier in the container is not the fully qualified class name of the service (this is often true if you are not using a container supporting autowiring) +- You do not want to inject a service in a domain object +- You simply do not like the magic of injecting services in a method signature + +If you do not want to use autowiring and if you still need to access services to compute a field, please read on +the next chapter to learn [how to extend a type](extend-type). diff --git a/website/versioned_docs/version-8.0.0/custom-types.mdx b/website/versioned_docs/version-8.0.0/custom-types.mdx new file mode 100644 index 0000000000..3db2bec5bd --- /dev/null +++ b/website/versioned_docs/version-8.0.0/custom-types.mdx @@ -0,0 +1,219 @@ +--- +id: custom-types +title: Custom types +sidebar_label: Custom types +--- + +In some special cases, you want to override the GraphQL return type that is attributed by default by GraphQLite. + +For instance: + +```php +#[Type(class: Product::class)] +class ProductType +{ + #[Field] + public function getId(Product $source): string + { + return $source->getId(); + } +} +``` + +In the example above, GraphQLite will generate a GraphQL schema with a field `id` of type `string`: + +```graphql +type Product { + id: String! +} +``` + +GraphQL comes with an `ID` scalar type. But PHP has no such type. So GraphQLite does not know when a variable +is an `ID` or not. + +You can help GraphQLite by manually specifying the output type to use: + +```php + #[Field(outputType: "ID")] +``` + +## Usage + +The `outputType` attribute will map the return value of the method to the output type passed in parameter. + +You can use the `outputType` attribute in the following annotations: + +* `#[Query]` +* `#[Mutation]` +* `#[Subscription]` +* `#[Field]` +* `#[SourceField]` +* `#[MagicField]` + +## Registering a custom output type (advanced) + +In order to create a custom output type, you need to: + +1. Design a class that extends `GraphQL\Type\Definition\ObjectType`. +2. Register this class in the GraphQL schema. + +You'll find more details on the [Webonyx documentation](https://webonyx.github.io/graphql-php/type-definitions/object-types/). + +--- + +In order to find existing types, the schema is using *type mappers* (classes implementing the `TypeMapperInterface` interface). + +You need to make sure that one of these type mappers can return an instance of your type. The way you do this will depend on the framework +you use. + +### Symfony users + +Any class extending `GraphQL\Type\Definition\ObjectType` (and available in the container) will be automatically detected +by Symfony and added to the schema. + +If you want to automatically map the output type to a given PHP class, you will have to explicitly declare the output type +as a service and use the `graphql.output_type` tag: + +```yaml +# config/services.yaml +services: + App\MyOutputType: + tags: + - { name: 'graphql.output_type', class: 'App\MyPhpClass' } +``` + +### Other frameworks + +The easiest way is to use a `StaticTypeMapper`. Use this class to register custom output types. + +```php +// Sample code: +$staticTypeMapper = new StaticTypeMapper( + // Let's register a type that maps by default to the "MyClass" PHP class + types: [ + MyClass::class => new MyCustomOutputType() + ], + + // If you don't want your output type to map to any PHP class by default, use: + notMappedTypes: [ + new MyCustomOutputType() + ], +); + +// Register the static type mapper in your application using the SchemaFactory instance +$schemaFactory->addTypeMapper($staticTypeMapper); +``` + +## Registering a custom scalar type (advanced) + +If you need to add custom scalar types, first, check the [GraphQLite Misc. Types library](https://github.com/thecodingmachine/graphqlite-misc-types). +It contains a number of "out-of-the-box" scalar types ready to use and you might find what you need there. + +You still need to develop your custom scalar type? Ok, let's get started. + +In order to add a scalar type in GraphQLite, you need to: + +- create a [Webonyx custom scalar type](https://webonyx.github.io/graphql-php/type-system/scalar-types/#writing-custom-scalar-types). + You do this by creating a class that extends `GraphQL\Type\Definition\ScalarType`. +- create a "type mapper" that will map PHP types to the GraphQL scalar type. You do this by writing a class implementing the `RootTypeMapperInterface`. +- create a "type mapper factory" that will be in charge of creating your "type mapper". + +```php +interface RootTypeMapperInterface +{ + /** + * @param \ReflectionMethod|\ReflectionProperty $reflector + */ + public function toGraphQLOutputType(Type $type, ?OutputType $subType, $reflector, DocBlock $docBlockObj): OutputType; + + /** + * @param \ReflectionMethod|\ReflectionProperty $reflector + */ + public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, $reflector, DocBlock $docBlockObj): InputType; + + public function mapNameToType(string $typeName): NamedType; +} +``` + +The `toGraphQLOutputType` and `toGraphQLInputType` are meant to map a return type (for output types) or a parameter type (for input types) +to your GraphQL scalar type. Return your scalar type if there is a match or `null` if there no match. + +The `mapNameToType` should return your GraphQL scalar type if `$typeName` is the name of your scalar type. + +RootTypeMapper are organized **in a chain** (they are actually middlewares). +Each instance of a `RootTypeMapper` holds a reference on the next root type mapper to be called in the chain. + +For instance: + +```php +class AnyScalarTypeMapper implements RootTypeMapperInterface +{ + /** @var RootTypeMapperInterface */ + private $next; + + public function __construct(RootTypeMapperInterface $next) + { + $this->next = $next; + } + + public function toGraphQLOutputType(Type $type, ?OutputType $subType, ReflectionMethod $refMethod, DocBlock $docBlockObj): ?OutputType + { + if ($type instanceof Scalar) { + // AnyScalarType is a class implementing the Webonyx ScalarType type. + return AnyScalarType::getInstance(); + } + // If the PHPDoc type is not "Scalar", let's pass the control to the next type mapper in the chain + return $this->next->toGraphQLOutputType($type, $subType, $refMethod, $docBlockObj); + } + + public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, ReflectionMethod $refMethod, DocBlock $docBlockObj): ?InputType + { + if ($type instanceof Scalar) { + // AnyScalarType is a class implementing the Webonyx ScalarType type. + return AnyScalarType::getInstance(); + } + // If the PHPDoc type is not "Scalar", let's pass the control to the next type mapper in the chain + return $this->next->toGraphQLInputType($type, $subType, $argumentName, $refMethod, $docBlockObj); + } + + /** + * Returns a GraphQL type by name. + * If this root type mapper can return this type in "toGraphQLOutputType" or "toGraphQLInputType", it should + * also map these types by name in the "mapNameToType" method. + * + * @param string $typeName The name of the GraphQL type + * @return NamedType|null + */ + public function mapNameToType(string $typeName): ?NamedType + { + if ($typeName === AnyScalarType::NAME) { + return AnyScalarType::getInstance(); + } + return null; + } +} +``` + +Now, in order to create an instance of your `AnyScalarTypeMapper` class, you need an instance of the `$next` type mapper in the chain. +How do you get the `$next` type mapper? Through a factory: + +```php +class AnyScalarTypeMapperFactory implements RootTypeMapperFactoryInterface +{ + public function create(RootTypeMapperInterface $next, RootTypeMapperFactoryContext $context): RootTypeMapperInterface + { + return new AnyScalarTypeMapper($next); + } +} +``` + +Now, you need to register this factory in your application, and we are done. + +You can register your own root mapper factories using the `SchemaFactory::addRootTypeMapperFactory()` method. + +```php +$schemaFactory->addRootTypeMapperFactory(new AnyScalarTypeMapperFactory()); +``` + +If you are using the Symfony bundle, the factory will be automatically registered, you have nothing to do (the service +is automatically tagged with the "graphql.root_type_mapper_factory" tag). diff --git a/website/versioned_docs/version-8.0.0/doctrine-annotations-attributes.mdx b/website/versioned_docs/version-8.0.0/doctrine-annotations-attributes.mdx new file mode 100644 index 0000000000..058c276a95 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/doctrine-annotations-attributes.mdx @@ -0,0 +1,102 @@ +--- +id: doctrine-annotations-attributes +title: Doctrine annotations VS PHP8 attributes +sidebar_label: Annotations VS Attributes +--- + +GraphQLite is heavily relying on the concept of annotations (also called attributes in PHP 8+). + +## Doctrine annotations + +
+ Unsupported! Doctrine annotations are replaced in favor of native PHP 8 attributes. +
+ +## PHP 8 attributes + +Starting with PHP 8, PHP got native annotations support. They are actually called "attributes" in the PHP world. + +The same code can be written this way: + +```php +use TheCodingMachine\GraphQLite\Annotations\Type; + +#[Type] +class MyType +{ +} +``` + +GraphQLite v4.1+ has support for PHP 8 attributes. + +The Doctrine annotation class and the PHP 8 attribute class is **the same** (so you will be using the same `use` statement at the top of your file). + +They support the same attributes too. + +A few notable differences: + +- PHP 8 attributes can be written at the parameter level. Any attribute targeting a "parameter" must be written at the parameter level. + +Let's take an example with the [`#Autowire` attribute](autowiring.mdx): + +```php +#[Field] +public function getProduct(#[Autowire] ProductRepository $productRepository) : Product { + //... +} +``` + +## Migrating from Doctrine annotations to PHP 8 attributes + +The good news is that you can easily migrate from Doctrine annotations to PHP 8 attributes using the amazing, [Rector library](https://github.com/rectorphp/rector). To do so, you'll want to use the following rector configuration: + +```php title="rector.php" +import(SetList::CODE_QUALITY); + + // Set parameters + $parameters = $containerConfigurator->parameters(); + $parameters->set(Option::PATHS, [ + __DIR__ . '/src', + __DIR__ . '/tests', + ]); + + $services = $containerConfigurator->services(); + + // @Validate and @Assertion are part of other libraries, include if necessary + $services->set(AnnotationToAttributeRector::class) + ->configure([ + new AnnotationToAttribute(GraphQLite\Query::class), + new AnnotationToAttribute(GraphQLite\Mutation::class), + new AnnotationToAttribute(GraphQLite\Type::class), + new AnnotationToAttribute(GraphQLite\ExtendType::class), + new AnnotationToAttribute(GraphQLite\Input::class), + new AnnotationToAttribute(GraphQLite\Field::class), + new AnnotationToAttribute(GraphQLite\SourceField::class), + new AnnotationToAttribute(GraphQLite\MagicField::class), + new AnnotationToAttribute(GraphQLite\Logged::class), + new AnnotationToAttribute(GraphQLite\Right::class), + new AnnotationToAttribute(GraphQLite\FailWith::class), + new AnnotationToAttribute(GraphQLite\HideIfUnauthorized::class), + new AnnotationToAttribute(GraphQLite\InjectUser::class), + new AnnotationToAttribute(GraphQLite\Security::class), + new AnnotationToAttribute(GraphQLite\Factory::class), + new AnnotationToAttribute(GraphQLite\UseInputType::class), + new AnnotationToAttribute(GraphQLite\Decorate::class), + new AnnotationToAttribute(GraphQLite\Autowire::class), + new AnnotationToAttribute(GraphQLite\HideParameter::class), + new AnnotationToAttribute(GraphQLite\EnumType::class), + ]); +}; +``` diff --git a/website/versioned_docs/version-8.0.0/error-handling.mdx b/website/versioned_docs/version-8.0.0/error-handling.mdx new file mode 100644 index 0000000000..1fe32a54de --- /dev/null +++ b/website/versioned_docs/version-8.0.0/error-handling.mdx @@ -0,0 +1,144 @@ +--- +id: error-handling +title: Error handling +sidebar_label: Error handling +--- + +In GraphQL, when an error occurs, the server must add an "error" entry in the response. + +```json +{ + "errors": [ + { + "message": "Name for character with ID 1002 could not be fetched.", + "locations": [ { "line": 6, "column": 7 } ], + "path": [ "hero", "heroFriends", 1, "name" ] + } + ] +} +``` + +You can generate such errors with GraphQLite by throwing a `GraphQLException`. + +```php +use TheCodingMachine\GraphQLite\Exceptions\GraphQLException; + +throw new GraphQLException("Exception message"); +``` + +## HTTP response code + +By default, when you throw a `GraphQLException`, the HTTP status code will be 500. + +If your exception code is in the 4xx - 5xx range, the exception code will be used as an HTTP status code. + +```php +// This exception will generate a HTTP 404 status code +throw new GraphQLException("Not found", 404); +``` + +
GraphQL allows to have several errors for one request. If you have several +GraphQLException thrown for the same request, the HTTP status code used will be the highest one.
+ +## Customizing the extensions section + +You can customize the whole "extensions" section with the 5th parameter of the constructor: + +```php +throw new GraphQLException("Field required", 400, null, "VALIDATION", ['field' => 'name']); +``` + +will generate: + +```json +{ + "errors": [ + { + "message": "Field required", + "extensions": { + "field": "name" + } + } + ] +} +``` + +## Writing your own exceptions + +Rather that throwing the base `GraphQLException`, you should consider writing your own exception. + +Any exception that implements interface `TheCodingMachine\GraphQLite\Exceptions\GraphQLExceptionInterface` will be displayed +in the GraphQL "errors" section. + +```php +class ValidationException extends Exception implements GraphQLExceptionInterface +{ + /** + * Returns true when exception message is safe to be displayed to a client. + */ + public function isClientSafe(): bool + { + return true; + } + + /** + * Returns the "extensions" object attached to the GraphQL error. + * + * @return array + */ + public function getExtensions(): array + { + return []; + } +} +``` + +## Many errors for one exception + +Sometimes, you need to display several errors in the response. But of course, at any given point in your code, you can +throw only one exception. + +If you want to display several exceptions, you can bundle these exceptions in a `GraphQLAggregateException` that you can +throw. + +```php +use TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException; + +#[Query] +public function createProduct(string $name, float $price): Product +{ + $exceptions = new GraphQLAggregateException(); + + if ($name === '') { + $exceptions->add(new GraphQLException('Name cannot be empty', 400, null, 'VALIDATION')); + } + if ($price <= 0) { + $exceptions->add(new GraphQLException('Price must be positive', 400, null, 'VALIDATION')); + } + + if ($exceptions->hasExceptions()) { + throw $exceptions; + } +} +``` + +## Webonyx exceptions + +GraphQLite is based on the wonderful webonyx/GraphQL-PHP library. Therefore, the Webonyx exception mechanism can +also be used in GraphQLite. This means you can throw a `GraphQL\Error\Error` exception or any exception implementing +[`GraphQL\Error\ClientAware` interface](http://webonyx.github.io/graphql-php/error-handling/#errors-in-graphql) + +Actually, the `TheCodingMachine\GraphQLite\Exceptions\GraphQLExceptionInterface` extends Webonyx's `ClientAware` interface. + +## Behaviour of exceptions that do not implement ClientAware + +If an exception that does not implement `ClientAware` is thrown, by default, GraphQLite will not catch it. + +The exception will propagate to your framework error handler/middleware that is in charge of displaying the classical error page. + +You can [change the underlying behaviour of Webonyx to catch any exception and turn them into GraphQL errors](http://webonyx.github.io/graphql-php/error-handling/#debugging-tools). +The way you adjust the error settings depends on the framework you are using ([Symfony](symfony-bundle.md), [Laravel](laravel-package.md)). + +
To be clear: we strongly discourage changing this setting. We strongly believe that the +default "RETHROW_UNSAFE_EXCEPTIONS" setting of Webonyx is the only sane setting (only putting in "errors" section exceptions +designed for GraphQL).
diff --git a/website/versioned_docs/version-8.0.0/extend-input-type.mdx b/website/versioned_docs/version-8.0.0/extend-input-type.mdx new file mode 100644 index 0000000000..abe2818dd9 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/extend-input-type.mdx @@ -0,0 +1,75 @@ +--- +id: extend-input-type +title: Extending an input type +sidebar_label: Extending an input type +--- + +Available in GraphQLite 4.0+ + +
If you are not familiar with the #[Factory] tag, read first the "input types" guide.
+ +Fields exposed in a GraphQL input type do not need to be all part of the factory method. + +Just like with output type (that can be [extended using the `ExtendType` attribute](extend-type.mdx)), you can extend/modify +an input type using the `#[Decorate]` attribute. + +Use the `#[Decorate]` attribute to add additional fields to an input type that is already declared by a `#[Factory]` attribute, +or to modify the returned object. + +
+ The #[Decorate] attribute is very useful in scenarios where you cannot touch the #[Factory] method. + This can happen if the #[Factory] method is defined in a third-party library or if the #[Factory] method is part + of auto-generated code. +
+ +Let's assume you have a `Filter` class used as an input type. You most certainly have a `#[Factory]` to create the input type. + +```php +class MyFactory +{ + #[Factory] + public function createFilter(string $name): Filter + { + // Let's assume you have a flexible 'Filter' class that can accept any kind of filter + $filter = new Filter(); + $filter->addFilter('name', $name); + return $filter; + } +} +``` + +Assuming you **cannot** modify the code of this factory, you can still modify the GraphQL input type generated by +adding a "decorator" around the factory. + +```php +class MyDecorator +{ + #[Decorate(inputTypeName: "FilterInput")] + public function addTypeFilter(Filter $filter, string $type): Filter + { + $filter->addFilter('type', $type); + return $filter; + } +} +``` + +In the example above, the "Filter" input type is modified. We add an additional "type" field to the input type. + +A few things to notice: + +- The decorator takes the object generated by the factory as first argument +- The decorator MUST return an object of the same type (or a sub-type) +- The decorator CAN contain additional parameters. They will be added to the fields of the GraphQL input type. +- The `#[Decorate]` attribute must contain a `inputTypeName` attribute that contains the name of the GraphQL input type + that is decorated. If you did not specify this name in the `#[Factory]` attribute, this is by default the name of the + PHP class + "Input" (for instance: "Filter" => "FilterInput") + + +
+ Heads up! The MyDecorator class must exist in the container of your + application and the container identifier MUST be the fully qualified class name. +

+ If you are using the Symfony bundle (or a framework with autowiring like Laravel), this is usually + not an issue as the container will automatically create the controller entry if you do not explicitly + declare it. +
diff --git a/website/versioned_docs/version-8.0.0/extend-type.mdx b/website/versioned_docs/version-8.0.0/extend-type.mdx new file mode 100644 index 0000000000..44d5ac9327 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/extend-type.mdx @@ -0,0 +1,135 @@ +--- +id: extend-type +title: Extending a type +sidebar_label: Extending a type +--- + + +Fields exposed in a GraphQL type do not need to be all part of the same class. + +Use the `#[ExtendType]` attribute to add additional fields to a type that is already declared. + +
+ Extending a type has nothing to do with type inheritance. + If you are looking for a way to expose a class and its children classes, have a look at + the Inheritance section +
+ +Let's assume you have a `Product` class. In order to get the name of a product, there is no `getName()` method in +the product because the name needs to be translated in the correct language. You have a `TranslationService` to do that. + +```php +namespace App\Entities; + +use TheCodingMachine\GraphQLite\Annotations\Field; +use TheCodingMachine\GraphQLite\Annotations\Type; + +#[Type] +class Product +{ + // ... + + #[Field] + public function getId(): string + { + return $this->id; + } + + #[Field] + public function getPrice(): ?float + { + return $this->price; + } +} +``` + +```php +// You need to use a service to get the name of the product in the correct language. +$name = $translationService->getProductName($productId, $language); +``` + +Using `#[ExtendType]`, you can add an additional `name` field to your product: + +```php +namespace App\Types; + +use TheCodingMachine\GraphQLite\Annotations\ExtendType; +use TheCodingMachine\GraphQLite\Annotations\Field; +use App\Entities\Product; + +#[ExtendType(class: Product::class)] +class ProductType +{ + private $translationService; + + public function __construct(TranslationServiceInterface $translationService) + { + $this->translationService = $translationService; + } + + #[Field] + public function getName(Product $product, string $language): string + { + return $this->translationService->getProductName($product->getId(), $language); + } +} +``` + +Let's break this sample: + +```php +#[ExtendType(class: Product::class)] +``` + +With the `#[ExtendType]` attribute, we tell GraphQLite that we want to add fields in the GraphQL type mapped to +the `Product` PHP class. + +```php +class ProductType +{ + private $translationService; + + public function __construct(TranslationServiceInterface $translationService) + { + $this->translationService = $translationService; + } + + // ... +} +``` + + +- The `ProductType` class must be in the types namespace. You configured this namespace when you installed GraphQLite. +- The `ProductType` class is actually a **service**. You can therefore inject dependencies in it (like the `$translationService` in this example) + +
Heads up! The ProductType class must exist in the container of your +application and the container identifier MUST be the fully qualified class name.

+If you are using the Symfony bundle (or a framework with autowiring like Laravel), this +is usually not an issue as the container will automatically create the controller entry if you do not explicitly +declare it.
+ +```php +#[Field] +public function getName(Product $product, string $language): string +{ + return $this->translationService->getProductName($product->getId(), $language); +} +``` + +The `#[Field]` attribute is used to add the "name" field to the `Product` type. + +Take a close look at the signature. The first parameter is the "resolved object" we are working on. +Any additional parameters are used as arguments. + +Using the "[Type language](https://graphql.org/learn/schema/#type-language)" notation, we defined a type extension for +the GraphQL "Product" type: + +```graphql +Extend type Product { + name(language: !String): String! +} +``` + +
Type extension is a very powerful tool. Use it to add fields that needs to be +computed from services not available in the entity. +
diff --git a/website/versioned_docs/version-8.0.0/external-type-declaration.mdx b/website/versioned_docs/version-8.0.0/external-type-declaration.mdx new file mode 100644 index 0000000000..428fad7f8e --- /dev/null +++ b/website/versioned_docs/version-8.0.0/external-type-declaration.mdx @@ -0,0 +1,149 @@ +--- +id: external-type-declaration +title: External type declaration +sidebar_label: External type declaration +--- + +In some cases, you cannot or do not want to put an attribute on a domain class. + +For instance: + +* The class you want to annotate is part of a third party library and you cannot modify it +* You are doing domain-driven design and don't want to clutter your domain object with attributes from the view layer +* etc. + +## `#[Type]` attribute with the `class` attribute + +GraphQLite allows you to use a *proxy* class thanks to the `#[Type]` attribute with the `class` attribute: + +```php +namespace App\Types; + +use TheCodingMachine\GraphQLite\Annotations\Type; +use TheCodingMachine\GraphQLite\Annotations\Field; +use App\Entities\Product; + +#[Type(class: Product::class)] +class ProductType +{ + #[Field] + public function getId(Product $product): string + { + return $product->getId(); + } +} +``` + +The `ProductType` class must be in the *types* namespace. You configured this namespace when you installed GraphQLite. + +The `ProductType` class is actually a **service**. You can therefore inject dependencies in it. + +
Heads up! The ProductType class must exist in the container of your application and the container identifier MUST be the fully qualified class name.

+If you are using the Symfony bundle (or a framework with autowiring like Laravel), this +is usually not an issue as the container will automatically create the controller entry if you do not explicitly +declare it.
+ +In methods with a `#[Field]` attribute, the first parameter is the *resolved object* we are working on. Any additional parameters are used as arguments. + +## `#[SourceField]` attribute + +If you don't want to rewrite all *getters* of your base class, you may use the `#[SourceField]` attribute: + +```php +use TheCodingMachine\GraphQLite\Annotations\Type; +use TheCodingMachine\GraphQLite\Annotations\SourceField; +use App\Entities\Product; + +#[Type(class: Product::class)] +#[SourceField(name: "name")] +#[SourceField(name: "price")] +class ProductType +{ +} +``` + +By doing so, you let GraphQLite know that the type exposes the `getName` method of the underlying `Product` object. + +Internally, GraphQLite will look for methods named `name()`, `getName()` and `isName()`). +You can set different name to look for with `sourceName` attribute. + +## `#[MagicField]` attribute + +If your object has no getters, but instead uses magic properties (using the magic `__get` method), you should use the `#[MagicField]` attribute: + +```php +use TheCodingMachine\GraphQLite\Annotations\Type; +use TheCodingMachine\GraphQLite\Annotations\SourceField; +use App\Entities\Product; + +#[Type] +#[MagicField(name: "name", outputType: "String!")] +#[MagicField(name: "price", outputType: "Float")] +class ProductType +{ + public function __get(string $property) { + // return some magic property + } +} +``` + +By doing so, you let GraphQLite know that the type exposes "name" and the "price" magic properties of the underlying `Product` object. +You can set different name to look for with `sourceName` attribute. + +This is particularly useful in frameworks like Laravel, where Eloquent is making a very wide use of such properties. + +Please note that GraphQLite has no way to know the type of a magic property. Therefore, you have specify the GraphQL type +of each property manually. + +### Authentication and authorization + +You may also check for logged users or users with a specific right using the "annotations" argument. + +```php +use TheCodingMachine\GraphQLite\Annotations\Type; +use TheCodingMachine\GraphQLite\Annotations\SourceField; +use TheCodingMachine\GraphQLite\Annotations\Logged; +use TheCodingMachine\GraphQLite\Annotations\Right; +use TheCodingMachine\GraphQLite\Annotations\FailWith; +use App\Entities\Product; + +#[Type(class: Product::class)] +#[SourceField(name: "name")] +#[SourceField(name: "price", annotations: [new Logged(), new Right("CAN_ACCESS_Price"), new FailWith(null)])] +class ProductType extends AbstractAnnotatedObjectType +{ +} +``` + +Any attributes described in the [Authentication and authorization page](authentication-authorization.mdx), or any attribute this is actually a ["field middleware"](field-middlewares.md) can be used in the `#[SourceField]` "annotations" argument. + +## Declaring fields dynamically (without attributes) + +In some very particular cases, you might not know exactly the list of `#[SourceField]` attributes at development time. +If you need to decide the list of `#[SourceField]` at runtime, you can implement the `FromSourceFieldsInterface`: + +```php +use TheCodingMachine\GraphQLite\FromSourceFieldsInterface; + +#[Type(class: Product::class)] +class ProductType implements FromSourceFieldsInterface +{ + /** + * Dynamically returns the array of source fields + * to be fetched from the original object. + * + * @return SourceFieldInterface[] + */ + public function getSourceFields(): array + { + // You may want to enable fields conditionally based on feature flags... + if (ENABLE_STATUS_GLOBALLY) { + return [ + new SourceField(['name'=>'status', 'annotations'=>[new Logged()]]), + ]; + } else { + return []; + } + } +} +``` diff --git a/website/versioned_docs/version-8.0.0/field-middlewares.md b/website/versioned_docs/version-8.0.0/field-middlewares.md new file mode 100644 index 0000000000..df7b2bfaee --- /dev/null +++ b/website/versioned_docs/version-8.0.0/field-middlewares.md @@ -0,0 +1,135 @@ +--- +id: field-middlewares +title: Adding custom attributes with Field middlewares +sidebar_label: Custom attributes +--- + +Available in GraphQLite 4.0+ + +Just like the `#[Logged]` or `#[Right]` attribute, you can develop your own attribute that extends/modifies the behaviour of a field/query/mutation. + +
+ If you want to create an attribute that targets a single argument (like #[AutoWire]), you should rather check the documentation about custom argument resolving +
+ +## Field middlewares + +GraphQLite is based on the Webonyx/Graphql-PHP library. In Webonyx, fields are represented by the `FieldDefinition` class. +In order to create a `FieldDefinition` instance for your field, GraphQLite goes through a series of "middlewares". + +![](/img/field_middleware.svg) + +Each middleware is passed a `TheCodingMachine\GraphQLite\QueryFieldDescriptor` instance. This object contains all the +parameters used to initialize the field (like the return type, the list of arguments, the resolver to be used, etc...) + +Each middleware must return a `GraphQL\Type\Definition\FieldDefinition` (the object representing a field in Webonyx/GraphQL-PHP). + +```php +/** + * Your middleware must implement this interface. + */ +interface FieldMiddlewareInterface +{ + public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandlerInterface $fieldHandler): ?FieldDefinition; +} +``` + +```php +class QueryFieldDescriptor +{ + public function getName() { /* ... */ } + public function withName(string $name): self { /* ... */ } + public function getType() { /* ... */ } + public function withType($type): self { /* ... */ } + public function getParameters(): array { /* ... */ } + public function withParameters(array $parameters): self { /* ... */ } + public function withCallable(callable $callable): self { /* ... */ } + public function withTargetMethodOnSource(?string $targetMethodOnSource): self { /* ... */ } + public function isInjectSource(): bool { /* ... */ } + public function withInjectSource(bool $injectSource): self { /* ... */ } + public function getComment(): ?string { /* ... */ } + public function withComment(?string $comment): self { /* ... */ } + public function getMiddlewareAnnotations(): MiddlewareAnnotations { /* ... */ } + public function withMiddlewareAnnotations(MiddlewareAnnotations $middlewareAnnotations): self { /* ... */ } + public function getOriginalResolver(): ResolverInterface { /* ... */ } + public function getResolver(): callable { /* ... */ } + public function withResolver(callable $resolver): self { /* ... */ } +} +``` + +The role of a middleware is to analyze the `QueryFieldDescriptor` and modify it (or to directly return a `FieldDefinition`). + +If you want the field to purely disappear, your middleware can return `null`, although this should be used with caution: +field middlewares only get called once per Schema instance. If you use a long-running server (like Laravel Octane, Swoole, RoadRunner etc) +and share the same Schema instance across requests, you will not be able to hide fields based on request data. + +## Attributes parsing + +Take a look at the `QueryFieldDescriptor::getMiddlewareAnnotations()`. + +It returns the list of attributes applied to your field that implements the `MiddlewareAnnotationInterface`. + +Let's imagine you want to add a `#[OnlyDebug]` attribute that displays a field/query/mutation only in debug mode (and +hides the field in production). That could be useful, right? + +First, we have to define the attribute. + +```php title="OnlyDebug.php" +namespace App\Annotations; + +use Attribute; +use TheCodingMachine\GraphQLite\Annotations\MiddlewareAnnotationInterface; + +#[Attribute(Attribute::TARGET_METHOD)] +class OnlyDebug implements MiddlewareAnnotationInterface +{ +} +``` + +Apart from being a classical attribute, this class implements the `MiddlewareAnnotationInterface`. This interface is a "marker" interface. It does not have any methods. It is just used to tell GraphQLite that this attribute is to be used by middlewares. + +Now, we can write a middleware that will act upon this attribute. + +```php +namespace App\Middlewares; + +use App\Annotations\OnlyDebug; +use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewareInterface; +use GraphQL\Type\Definition\FieldDefinition; +use TheCodingMachine\GraphQLite\QueryFieldDescriptor; + +/** + * Middleware in charge of hiding a field if it is annotated with #[OnlyDebug] and the DEBUG constant is not set + */ +class OnlyDebugFieldMiddleware implements FieldMiddlewareInterface +{ + public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandlerInterface $fieldHandler): ?FieldDefinition + { + $annotations = $queryFieldDescriptor->getMiddlewareAnnotations(); + + /** + * @var OnlyDebug $onlyDebug + */ + $onlyDebug = $annotations->getAnnotationByType(OnlyDebug::class); + + if ($onlyDebug !== null && !DEBUG) { + // If the onlyDebug attribute is present, returns null. + // Returning null will hide the field. + return null; + } + + // Otherwise, let's continue the middleware pipe without touching anything. + return $fieldHandler->handle($queryFieldDescriptor); + } +} +``` + +The final thing we have to do is to register the middleware. + +- Assuming you are using the `SchemaFactory` to initialize GraphQLite, you can register the field middleware using: + + ```php + $schemaFactory->addFieldMiddleware(new OnlyDebugFieldMiddleware()); + ``` + +- If you are using the Symfony bundle, you can register your field middleware services by tagging them with the `graphql.field_middleware` tag. diff --git a/website/versioned_docs/version-8.0.0/file-uploads.mdx b/website/versioned_docs/version-8.0.0/file-uploads.mdx new file mode 100644 index 0000000000..487a413948 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/file-uploads.mdx @@ -0,0 +1,60 @@ +--- +id: file-uploads +title: File uploads +sidebar_label: File uploads +--- + +GraphQL does not support natively the notion of file uploads, but an extension to the GraphQL protocol was proposed +to add support for [multipart requests](https://github.com/jaydenseric/graphql-multipart-request-spec). + +## Installation + +GraphQLite supports this extension through the use of the [Ecodev/graphql-upload](https://github.com/Ecodev/graphql-upload) library. + +You must start by installing this package: + +```console +$ composer require ecodev/graphql-upload +``` + +### If you are using the Symfony bundle + +If you are using our Symfony bundle, the file upload middleware is managed by the bundle. You have nothing to do +and can start using it right away. + +### If you are using a PSR-15 compatible framework + +In order to use this, you must first be sure that the `ecodev/graphql-upload` PSR-15 middleware is part of your middleware pipe. + +Simply add `GraphQL\Upload\UploadMiddleware` to your middleware pipe. + +### If you are using another framework not compatible with PSR-15 + +Please check the Ecodev/graphql-upload library [documentation](https://github.com/Ecodev/graphql-upload) +for more information on how to integrate it in your framework. + +## Usage + +To handle an uploaded file, you type-hint against the PSR-7 `UploadedFileInterface`: + +```php +class MyController +{ + #[Mutation] + public function saveDocument(string $name, UploadedFileInterface $file): Document + { + // Some code that saves the document. + $file->moveTo($someDir); + } +} +``` + +Of course, you need to use a GraphQL client that is compatible with multipart requests. See [jaydenseric/graphql-multipart-request-spec](https://github.com/jaydenseric/graphql-multipart-request-spec#client) for a list of compatible clients. + +The GraphQL client must send the file using the Upload type. + +```graphql +mutation upload($file: Upload!) { + upload(file: $file) +} +``` diff --git a/website/versioned_docs/version-8.0.0/fine-grained-security.mdx b/website/versioned_docs/version-8.0.0/fine-grained-security.mdx new file mode 100644 index 0000000000..3fac998ff4 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/fine-grained-security.mdx @@ -0,0 +1,179 @@ +--- +id: fine-grained-security +title: Fine grained security +sidebar_label: Fine grained security +--- + + +If the [`#[Logged]` and `#[Right]` attributes](authentication-authorization.mdx#logged-and-right-annotations) are not +granular enough for your needs, you can use the advanced `#[Security]` attribute. + +Using the `#[Security]` attribute, you can write an *expression* that can contain custom logic. For instance: + +- Check that a user can access a given resource +- Check that a user has one right or another right +- ... + +## Using the #[Security] attribute + +The `#[Security]` attribute is very flexible: it allows you to pass an expression that can contains custom logic: + +```php +use TheCodingMachine\GraphQLite\Annotations\Security; + +// ... + +#[Query] +#[Security("is_granted('ROLE_ADMIN') or is_granted('POST_SHOW', post)")] +public function getPost(Post $post): array +{ + // ... +} +``` + +The *expression* defined in the `#[Security]` attribute must conform to [Symfony's Expression Language syntax](https://symfony.com/doc/4.4/components/expression_language/syntax.html) + +
+ If you are a Symfony user, you might already be used to the #[Security] attribute. Most of the inspiration + of this attribute comes from Symfony. Warning though! GraphQLite's #[Security] attribute and + Symfony's #[Security] attribute are slightly different. Especially, the two attributes do not live + in the same namespace! +
+ +## The `is_granted` function + +Use the `is_granted` function to check if a user has a special right. + +```php +#[Security("is_granted('ROLE_ADMIN')")] +``` + +is similar to + +```php +#[Right("ROLE_ADMIN")] +``` + +In addition, the `is_granted` function accepts a second optional parameter: the "scope" of the right. + +```php +#[Query] +#[Security("is_granted('POST_SHOW', post)")] +public function getPost(Post $post): array +{ + // ... +} +``` + +In the example above, the `getPost` method can be called only if the logged user has the 'POST_SHOW' permission on the +`$post` object. You can notice that the `$post` object comes from the parameters. + +## Accessing method parameters + +All parameters passed to the method can be accessed in the `#[Security]` expression. + +```php +#[Query] +#[Security(expression: "startDate < endDate", statusCode: 400, message: "End date must be after start date")] +public function getPosts(DateTimeImmutable $startDate, DateTimeImmutable $endDate): array +{ + // ... +} +``` + +In the example above, we tweak a bit the Security attribute purpose to do simple input validation. + +## Setting HTTP code and error message + +You can use the `statusCode` and `message` attributes to set the HTTP code and GraphQL error message. + +```php +#[Query] +#[Security(expression: "is_granted('POST_SHOW', post)", statusCode: 404, message: "Post not found (let's pretend the post does not exists!)")] +public function getPost(Post $post): array +{ + // ... +} +``` + +Note: since a single GraphQL call contain many errors, 2 errors might have conflicting HTTP status code. +The resulting status code is up to the GraphQL middleware you use. Most of the time, the status code with the +higher error code will be returned. + +## Setting a default value + +If you do not want an error to be thrown when the security condition is not met, you can use the `failWith` attribute +to set a default value. + +```php +#[Query] +#[Security(expression: "is_granted('CAN_SEE_MARGIN', this)", failWith: null)] +public function getMargin(): float +{ + // ... +} +``` + +The `failWith` attribute behaves just like the [`#[FailWith]` attribute](authentication-authorization.mdx#not-throwing-errors) +but for a given `#[Security]` attribute. + +You cannot use the `failWith` attribute along `statusCode` or `message` attributes. + +## Accessing the user + +You can use the `user` variable to access the currently logged user. +You can use the `is_logged()` function to check if a user is logged or not. + + +```php +#[Query] +#[Security("is_logged() && user.age > 18")] +public function getNSFWImages(): array +{ + // ... +} +``` + +## Accessing the current object + +You can use the `this` variable to access any (public) property / method of the current class. + +```php +class Post { + #[Field] + #[Security("this.canAccessBody(user)")] + public function getBody(): array + { + // ... + } + + public function canAccessBody(User $user): bool + { + // Some custom logic here + } +} +``` + +## Available scope + +The `#[Security]` attribute can be used in any query, mutation or field, so anywhere you have a `#[Query]`, `#[Mutation]` +or `#[Field]` attribute. + +## How to restrict access to a given resource + +The `is_granted` method can be used to restrict access to a specific resource. + +```php +#[Security("is_granted('POST_SHOW', post)")] +``` + +If you are wondering how to configure these fine-grained permissions, this is not something that GraphQLite handles +itself. Instead, this depends on the framework you are using. + +If you are using Symfony, you will [create a custom voter](https://symfony.com/doc/current/security/voters.html). + +If you are using Laravel, you will [create a Gate or a Policy](https://laravel.com/docs/6.x/authorization). + +If you are using another framework, you need to know that the `is_granted` function simply forwards the call to +the `isAllowed` method of the configured `AuthorizationSerice`. See [Connecting GraphQLite to your framework's security module +](implementing-security.md) for more details diff --git a/website/versioned_docs/version-8.0.0/getting-started.md b/website/versioned_docs/version-8.0.0/getting-started.md new file mode 100644 index 0000000000..4eadcfa3fe --- /dev/null +++ b/website/versioned_docs/version-8.0.0/getting-started.md @@ -0,0 +1,16 @@ +--- +id: getting-started +title: Getting started +sidebar_label: Getting Started +--- + +GraphQLite is a framework agnostic library. You can use it in any PHP project as long as you know how to +inject services in your favorite framework's container. + +Currently, we provide bundle/packages to help you get started with Symfony, Laravel and any framework compatible +with container-interop/service-provider. + +- [Get started with Symfony](symfony-bundle.md) +- [Get started with Laravel](laravel-package.md) +- [Get started with a framework compatible with container-interop/service-provider](universal-service-providers.md) +- [Get started with another framework (or no framework)](other-frameworks.mdx) diff --git a/website/versioned_docs/version-8.0.0/implementing-security.md b/website/versioned_docs/version-8.0.0/implementing-security.md new file mode 100644 index 0000000000..88bbd8f7f2 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/implementing-security.md @@ -0,0 +1,57 @@ +--- +id: implementing-security +title: Connecting GraphQLite to your framework's security module +sidebar_label: Connecting security to your framework +--- + +
+ At the time of writing, the Symfony Bundle and the Laravel package handle this implementation. For the latest documentation, please see their respective Github repositories. +
+ +GraphQLite needs to know if a user is logged or not, and what rights it has. +But this is specific of the framework you use. + +To plug GraphQLite to your framework's security mechanism, you will have to provide two classes implementing: + +* `TheCodingMachine\GraphQLite\Security\AuthenticationServiceInterface` +* `TheCodingMachine\GraphQLite\Security\AuthorizationServiceInterface` + +Those two interfaces act as adapters between GraphQLite and your framework: + +```php +interface AuthenticationServiceInterface +{ + /** + * Returns true if the "current" user is logged + */ + public function isLogged(): bool; + + /** + * Returns an object representing the current logged user. + * Can return null if the user is not logged. + */ + public function getUser(): ?object; +} +``` + +```php +interface AuthorizationServiceInterface +{ + /** + * Returns true if the "current" user has access to the right "$right" + * + * @param mixed $subject The scope this right applies on. $subject is typically an object or a FQCN. Set $subject to "null" if the right is global. + */ + public function isAllowed(string $right, $subject = null): bool; +} +``` + +You need to write classes that implement these interfaces. Then, you must register those classes with GraphQLite. +It you are [using the `SchemaFactory`](other-frameworks.mdx), you can register your classes using: + +```php +// Configure an authentication service (to resolve the #[Logged] attribute). +$schemaFactory->setAuthenticationService($myAuthenticationService); +// Configure an authorization service (to resolve the #[Right] attribute). +$schemaFactory->setAuthorizationService($myAuthorizationService); +``` diff --git a/website/versioned_docs/version-8.0.0/inheritance-interfaces.mdx b/website/versioned_docs/version-8.0.0/inheritance-interfaces.mdx new file mode 100644 index 0000000000..5467492c52 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/inheritance-interfaces.mdx @@ -0,0 +1,162 @@ +--- +id: inheritance-interfaces +title: Inheritance and interfaces +sidebar_label: Inheritance and interfaces +--- + +## Modeling inheritance + +Some of your entities may extend other entities. GraphQLite will do its best to represent this hierarchy of objects in GraphQL using interfaces. + +Let's say you have two classes, `Contact` and `User` (which extends `Contact`): + +```php +#[Type] +class Contact +{ + // ... +} + +#[Type] +class User extends Contact +{ + // ... +} +``` + +Now, let's assume you have a query that returns a contact: + +```php +class ContactController +{ + #[Query] + public function getContact(): Contact + { + // ... + } +} +``` + +When writing your GraphQL query, you are able to use fragments to retrieve fields from the `User` type: + +```graphql +contact { + name + ... User { + email + } +} +``` + +Written in [GraphQL type language](https://graphql.org/learn/schema/#type-language), the representation of types +would look like this: + +```graphql +interface ContactInterface { + // List of fields declared in Contact class +} + +type Contact implements ContactInterface { + // List of fields declared in Contact class +} + +type User implements ContactInterface { + // List of fields declared in Contact and User classes +} +``` + +Behind the scene, GraphQLite will detect that the `Contact` class is extended by the `User` class. +Because the class is extended, a GraphQL `ContactInterface` interface is created dynamically. + +The GraphQL `User` type will also automatically implement this `ContactInterface`. The interface contains all the fields +available in the `Contact` type. + +## Mapping interfaces + +If you want to create a pure GraphQL interface, you can also add a `#[Type]` attribute on a PHP interface. + +```php +#[Type] +interface UserInterface +{ + #[Field] + public function getUserName(): string; +} +``` + +This will automatically create a GraphQL interface whose description is: + +```graphql +interface UserInterface { + userName: String! +} +``` + +### Implementing interfaces + +You don't have to do anything special to implement an interface in your GraphQL types. +Simply "implement" the interface in PHP and you are done! + +```php +#[Type] +class User implements UserInterface +{ + public function getUserName(): string; +} +``` + +This will translate in GraphQL schema as: + +```graphql +interface UserInterface { + userName: String! +} + +type User implements UserInterface { + userName: String! +} +``` + +Please note that you do not need to put the `#[Field]` attribute again in the implementing class. + +### Interfaces without an explicit implementing type + +You don't have to explicitly put a `#[Type]` attribute on the class implementing the interface (though this +is usually a good idea). + +```php +/** + * Look, this class has no #Type attribute + */ +class User implements UserInterface +{ + public function getUserName(): string; +} +``` + +```php +class UserController +{ + #[Query] + public function getUser(): UserInterface // This will work! + { + // ... + } +} +``` + +
If GraphQLite cannot find a proper GraphQL Object type implementing an interface, it +will create an object type "on the fly".
+ +In the example above, because the `User` class has no `#[Type]` attribute, GraphQLite will +create a `UserImpl` type that implements `UserInterface`. + +```graphql +interface UserInterface { + userName: String! +} + +type UserImpl implements UserInterface { + userName: String! +} +``` diff --git a/website/versioned_docs/version-8.0.0/input-types.mdx b/website/versioned_docs/version-8.0.0/input-types.mdx new file mode 100644 index 0000000000..9b5be40278 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/input-types.mdx @@ -0,0 +1,333 @@ +--- +id: input-types +title: Input types +sidebar_label: Input types +--- + +Let's assume you are developing an API that returns a list of cities around a location. + +Your GraphQL query might look like this: + +```php +class MyController +{ + /** + * @return City[] + */ + #[Query] + public function getCities(Location $location, float $radius): array + { + // Some code that returns an array of cities. + } +} + +// Class Location is a simple value-object. +class Location +{ + private $latitude; + private $longitude; + + public function __construct(float $latitude, float $longitude) + { + $this->latitude = $latitude; + $this->longitude = $longitude; + } + + public function getLatitude(): float + { + return $this->latitude; + } + + public function getLongitude(): float + { + return $this->longitude; + } +} +``` + +If you try to run this code, you will get the following error: + +``` +CannotMapTypeException: cannot map class "Location" to a known GraphQL input type. Check your TypeMapper configuration. +``` + +You are running into this error because GraphQLite does not know how to handle the `Location` object. + +In GraphQL, an object passed in parameter of a query or mutation (or any field) is called an **Input Type**. + +There are two ways for declaring that type, in GraphQLite: using the [`#[Input]` attribute](#input-attribute) or a [Factory method](#factory). + +## #\[Input\] Attribute + +Using the `#[Input]` attribute, we can transform the `Location` class, in the example above, into an input type. Just add the `#[Field]` attribute to the corresponding properties: + +```php +#[Input] +class Location +{ + + #[Field] + private ?string $name = null; + + #[Field] + private float $latitude; + + #[Field] + private float $longitude; + + public function __construct(float $latitude, float $longitude) + { + $this->latitude = $latitude; + $this->longitude = $longitude; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getLatitude(): float + { + return $this->latitude; + } + + public function getLongitude(): float + { + return $this->longitude; + } +} +``` + +Now if you call the `getCities` query, from the controller in the first example, the `Location` object will be automatically instantiated with the user provided, `latitude` / `longitude` properties, and passed to the controller as a parameter. + +There are some important things to notice: + +- The `#[Field]` attribute is recognized on properties for Input Type, as well as setters. +- There are 3 ways for fields to be resolved: + - Via constructor if corresponding properties are mentioned as parameters with the same names - exactly as in the example above. + - If properties are public, they will be just set without any additional effort - no constructor required. + - For private or protected properties implemented, a public setter is required (if they are not set via the constructor). For example `setLatitude(float $latitude)`. You can also put the `#[Field]` attribute on the setter, instead of the property, allowing you to have use many other attributes (`Security`, `Right`, `Autowire`, etc.). +- For validation of these Input Types, see the [Custom InputType Validation section](validation#custom-inputtype-validation). +- It's advised to use the `#[Input]` attribute on DTO style input type objects and not directly on your model objects. Using it on your model objects can cause coupling in undesirable ways. + +### Multiple Input Types from the same class + +Simple usage of the `#[Input]` attribute on a class creates a GraphQL input named by class name + "Input" suffix if a class name does not end with it already. Ex. `LocationInput` for `Location` class. + +You can add multiple `#[Input]` attributed to the same class, give them different names and link different fields. +Consider the following example: + +```php +#[Input(name: 'CreateUserInput', default: true)] +#[Input(name: 'UpdateUserInput', update: true)] +class UserInput +{ + + #[Field] + public string $username; + + #[Field(for: 'CreateUserInput')] + public string $email; + + #[Field(for: 'CreateUserInput', inputType: 'String!')] + #[Field(for: 'UpdateUserInput', inputType: 'String')] + public string $password; + + protected ?int $age; + + + #[Field] + public function setAge(?int $age): void + { + $this->age = $age; + } +} +``` + +There are 2 input types added to the `UserInput` class: `CreateUserInput` and `UpdateUserInput`. A few notes: +- `CreateUserInput` input will be used by default for this class. +- Field `username` is created for both input types, and it is required because the property type is not nullable. +- Field `email` will appear only for `CreateUserInput` input. +- Field `password` will appear for both. For `CreateUserInput` it'll be the required field and for `UpdateUserInput` optional. +- Field `age` is optional for both input types. + +Note that `update: true` argument for `UpdateUserInput`. It should be used when input type is used for a partial update, +It makes all fields optional and removes all default values from thus prevents setting default values via setters or directly to public properties. +In example above if you use the class as `UpdateUserInput` and set only `username` the other ones will be ignored. +In PHP 7 they will be set to `null`, while in PHP 8 they will be in not initialized state - this can be used as a trick +to check if user actually passed a value for a certain field. + +## Factory + +A **Factory** is a method that takes in parameter all the fields of the input type and return an object. + +Here is an example of factory: + +```php +class MyFactory +{ + /** + * The Factory attribute will create automatically a LocationInput input type in GraphQL. + */ + #[Factory] + public function createLocation(float $latitude, float $longitude): Location + { + return new Location($latitude, $longitude); + } +} +``` + +and now, you can run query like this: + +```graphql +query { + getCities(location: { + latitude: 45.0, + longitude: 0.0, + }, + radius: 42) + { + id, + name + } +} +``` + +- Factories must be declared with the **#[Factory]** attribute. +- The parameters of the factories are the field of the GraphQL input type + +A few important things to notice: + +- The container MUST contain the factory class. The identifier of the factory MUST be the fully qualified class name of the class that contains the factory. + This is usually already the case if you are using a container with auto-wiring capabilities +- We recommend that you put the factories in the same directories as the types. + +### Specifying the input type name + +The GraphQL input type name is derived from the return type of the factory. + +Given the factory below, the return type is "Location", therefore, the GraphQL input type will be named "LocationInput". + +```php +#[Factory] +public function createLocation(float $latitude, float $longitude): Location +{ + return new Location($latitude, $longitude); +} +``` + +In case you want to override the input type name, you can use the "name" attribute of the #[Factory] attribute: + +```php +#[Factory(name: 'MyNewInputName', default: true)] +``` + +Note that you need to add the "default" attribute is you want your factory to be used by default (more on this in +the next chapter). + +Unless you want to have several factories for the same PHP class, the input type name will be completely transparent +to you, so there is no real reason to customize it. + +### Forcing an input type + +You can use the `#[UseInputType]` attribute to force an input type of a parameter. + +Let's say you want to force a parameter to be of type "ID", you can use this: + +```php +#[Factory] +#[UseInputType(for: "$id", inputType:"ID!")] +public function getProductById(string $id): Product +{ + return $this->productRepository->findById($id); +} +``` + +### Declaring several input types for the same PHP class +Available in GraphQLite 4.0+ + +There are situations where a given PHP class might use one factory or another depending on the context. + +This is often the case when your objects map database entities. +In these cases, you can use combine the use of `#[UseInputType]` and `#[Factory]` attribute to achieve your goal. + +Here is an annotated sample: + +```php +/** + * This class contains 2 factories to create Product objects. + * The "getProduct" method is used by default to map "Product" classes. + * The "createProduct" method will generate another input type named "CreateProductInput" + */ +class ProductFactory +{ + // ... + + /** + * This factory will be used by default to map "Product" classes. + */ + #[Factory(name: "ProductRefInput", default: true)] + public function getProduct(string $id): Product + { + return $this->productRepository->findById($id); + } + /** + * We specify a name for this input type explicitly. + */ + #[Factory(name: "CreateProductInput", default: false)] + public function createProduct(string $name, string $type): Product + { + return new Product($name, $type); + } +} + +class ProductController +{ + /** + * The "createProduct" factory will be used for this mutation. + */ + #[Mutation] + #[UseInputType(for: "$product", inputType: "CreateProductInput!")] + public function saveProduct(Product $product): Product + { + // ... + } + + /** + * The default "getProduct" factory will be used for this query. + * + * @return Color[] + */ + #[Query] + public function availableColors(Product $product): array + { + // ... + } +} +``` + +### Ignoring some parameters +Available in GraphQLite 4.0+ + +GraphQLite will automatically map all your parameters to an input type. +But sometimes, you might want to avoid exposing some of those parameters. + +Image your `getProductById` has an additional `lazyLoad` parameter. This parameter is interesting when you call +directly the function in PHP because you can have some level of optimisation on your code. But it is not something that +you want to expose in the GraphQL API. Let's hide it! + +```php +#[Factory] +public function getProductById( + string $id, + #[HideParameter] + bool $lazyLoad = true + ): Product +{ + return $this->productRepository->findById($id, $lazyLoad); +} +``` + +With the `#[HideParameter]` attribute, you can choose to remove from the GraphQL schema any argument. + +To be able to hide an argument, the argument must have a default value. diff --git a/website/versioned_docs/version-8.0.0/internals.md b/website/versioned_docs/version-8.0.0/internals.md new file mode 100644 index 0000000000..93c7e958b8 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/internals.md @@ -0,0 +1,142 @@ +--- +id: internals +title: Internals +sidebar_label: Internals +--- + +## Mapping types + +The core of GraphQLite is its ability to map PHP types to GraphQL types. This mapping is performed by a series of +"type mappers". + +GraphQLite contains 4 categories of type mappers: + +- **Parameter mappers** +- **Root type mappers** +- **Recursive (class) type mappers** +- **(class) type mappers** + +```mermaid +graph TD; + classDef custom fill:#cfc,stroke:#7a7,stroke-width:2px,stroke-dasharray: 5, 5; + subgraph RootTypeMapperInterface + NullableTypeMapperAdapter-->CompoundTypeMapper + IteratorTypeMapper-->YourCustomRootTypeMapper + CompoundTypeMapper-->IteratorTypeMapper + YourCustomRootTypeMapper-->MyCLabsEnumTypeMapper + MyCLabsEnumTypeMapper-->EnumTypeMapper + EnumTypeMapper-->BaseTypeMapper + BaseTypeMapper-->FinalRootTypeMapper + end + subgraph RecursiveTypeMapperInterface + BaseTypeMapper-->RecursiveTypeMapper + end + subgraph TypeMapperInterface + RecursiveTypeMapper-->YourCustomTypeMapper + YourCustomTypeMapper-->PorpaginasTypeMapper + PorpaginasTypeMapper-->GlobTypeMapper + end + class YourCustomRootTypeMapper,YourCustomTypeMapper custom; +``` + +## Root type mappers + +(Classes implementing the [`RootTypeMapperInterface`](https://github.com/thecodingmachine/graphqlite/blob/master/src/Mappers/Root/RootTypeMapperInterface.php)) + +These type mappers are the first type mappers called. + +They are responsible for: + +- mapping scalar types (for instance mapping the "int" PHP type to GraphQL Integer type) +- detecting nullable/non-nullable types (for instance interpreting "?int" or "int|null") +- mapping list types (mapping a PHP array to a GraphQL list) +- mapping union types +- mapping enums + +Root type mappers have access to the *context* of a type: they can access the PHP DocBlock and read annotations. +If you want to write a custom type mapper that needs access to annotations, it needs to be a "root type mapper". + +GraphQLite provides 6 classes implementing `RootTypeMapperInterface`: + +- `NullableTypeMapperAdapter`: a type mapper in charge of making GraphQL types non-nullable if the PHP type is non-nullable +- `IteratorTypeMapper`: a type mapper in charge of iterable types (for instance: `MyIterator|User[]`) +- `CompoundTypeMapper`: a type mapper in charge of union types +- `MyCLabsEnumTypeMapper`: maps MyCLabs/enum types to GraphQL enum types (Deprecated: use native enums) +- `EnumTypeMapper`: maps PHP enums to GraphQL enum types +- `BaseTypeMapper`: maps scalar types and lists. Passes the control to the "recursive type mappers" if an object is encountered. +- `FinalRootTypeMapper`: the last type mapper of the chain, used to throw error if no other type mapper managed to handle the type. + +Type mappers are organized in a chain; each type-mapper is responsible for calling the next type mapper. + +```mermaid +graph TD; + classDef custom fill:#cfc,stroke:#7a7,stroke-width:2px,stroke-dasharray: 5, 5; + subgraph RootTypeMapperInterface + NullableTypeMapperAdapter-->CompoundTypeMapper + CompoundTypeMapper-->IteratorTypeMapper + IteratorTypeMapper-->YourCustomRootTypeMapper + YourCustomRootTypeMapper-->MyCLabsEnumTypeMapper + MyCLabsEnumTypeMapper-->EnumTypeMapper + EnumTypeMapper-->BaseTypeMapper + BaseTypeMapper-->FinalRootTypeMapper + end + class YourCustomRootTypeMapper custom; +``` + +## Class type mappers + +(Classes implementing the [`TypeMapperInterface`](https://github.com/thecodingmachine/graphqlite/blob/master/src/Mappers/TypeMapperInterface.php)) + +Class type mappers are mapping PHP classes to GraphQL object types. + +GraphQLite provide 3 default implementations: + +- `CompositeTypeMapper`: a type mapper that delegates mapping to other type mappers using the Composite Design Pattern. +- `GlobTypeMapper`: scans classes in a directory for the `#[Type]` or `#[ExtendType]` attribute and maps those to GraphQL types +- `PorpaginasTypeMapper`: maps and class implementing the Porpaginas `Result` interface to a [special paginated type](pagination.mdx). + +### Registering a type mapper in Symfony + +If you are using the GraphQLite Symfony bundle, you can register a type mapper by tagging the service with the "graphql.type_mapper" tag. + +### Registering a type mapper using the SchemaFactory + +If you are using the `SchemaFactory` to bootstrap GraphQLite, you can register a type mapper using the `SchemaFactory::addTypeMapper` method. + +## Recursive type mappers + +(Classes implementing the [`RecursiveTypeMapperInterface`](https://github.com/thecodingmachine/graphqlite/blob/master/src/Mappers/RecursiveTypeMapperInterface.php)) + +There is only one implementation of the `RecursiveTypeMapperInterface`: the `RecursiveTypeMapper`. + +Standard "class type mappers" are mapping a given PHP class to a GraphQL type. But they do not handle class hierarchies. +This is the role of the "recursive type mapper". + +Imagine that class "B" extends class "A" and class "A" maps to GraphQL type "AType". + +Since "B" *is a* "A", the "recursive type mapper" role is to make sure that "B" will also map to GraphQL type "AType". + +## Parameter mapper middlewares + +"Parameter middlewares" are used to decide what argument should be injected into a parameter. + +Let's have a look at a simple query: + +```php +/** + * @return Product[] + */ +#[Query] +public function products(ResolveInfo $info): array +``` + +As you may know, [the `ResolveInfo` object injected in this query comes from Webonyx/GraphQL-PHP library](query-plan.mdx). +GraphQLite knows that is must inject a `ResolveInfo` instance because it comes with a [`ResolveInfoParameterHandler`](https://github.com/thecodingmachine/graphqlite/blob/master/src/Mappers/Parameters/ResolveInfoParameterHandler.php) class +that implements the [`ParameterMiddlewareInterface`](https://github.com/thecodingmachine/graphqlite/blob/master/src/Mappers/Parameters/ParameterMiddlewareInterface.php)). + +You can register your own parameter middlewares using the `SchemaFactory::addParameterMiddleware()` method, or by tagging the +service as "graphql.parameter_middleware" if you are using the Symfony bundle. + +
+ Use a parameter middleware if you want to inject an argument in a method and if this argument is not a GraphQL input type or if you want to alter the way input types are imported (for instance if you want to add a validation step) +
diff --git a/website/versioned_docs/version-8.0.0/laravel-package-advanced.mdx b/website/versioned_docs/version-8.0.0/laravel-package-advanced.mdx new file mode 100644 index 0000000000..3341538023 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/laravel-package-advanced.mdx @@ -0,0 +1,221 @@ +--- +id: laravel-package-advanced +title: "Laravel package: advanced usage" +sidebar_label: Laravel specific features +--- + +
+ Be advised! This documentation will be removed in a future release. For current and up-to-date Laravel extension specific documentation, please see the Github repository. +
+ +The Laravel package comes with a number of features to ease the integration of GraphQLite in Laravel. + +## Support for Laravel validation rules + +The GraphQLite Laravel package comes with a special `#[Validate]` attribute to use Laravel validation rules in your +input types. + +```php +use TheCodingMachine\GraphQLite\Laravel\Annotations\Validate; + +class MyController +{ + #[Mutation] + public function createUser( + #[Validate("email|unique:users")] + string $email, + #[Validate("gte:8")] + string $password + ): User + { + // ... + } +} +``` + +You can use the `#[Validate]` attribute in any query / mutation / field / factory / decorator. + +If a validation fails to pass, the message will be printed in the "errors" section and you will get a HTTP 400 status code: + +```json +{ + "errors": [ + { + "message": "The email must be a valid email address.", + "extensions": { + "argument": "email" + } + }, + { + "message": "The password must be greater than or equal 8 characters.", + "extensions": { + "argument": "password" + } + } + ] +} +``` + +You can use any validation rule described in [the Laravel documentation](https://laravel.com/docs/6.x/validation#available-validation-rules) + +## Support for pagination + +In your query, if you explicitly return an object that extends the `Illuminate\Pagination\LengthAwarePaginator` class, +the query result will be wrapped in a "paginator" type. + +```php +class MyController +{ + /** + * @return Product[] + */ + #[Query] + public function products(): Illuminate\Pagination\LengthAwarePaginator + { + return Product::paginate(15); + } +} +``` + +Notice that: + +- the method return type MUST BE `Illuminate\Pagination\LengthAwarePaginator` or a class extending `Illuminate\Pagination\LengthAwarePaginator` +- you MUST add a `@return` statement to help GraphQLite find the type of the list + +Once this is done, you can get plenty of useful information about this page: + +```graphql +products { + items { # The items for the selected page + id + name + } + totalCount # The total count of items. + lastPage # Get the page number of the last available page. + firstItem # Get the "index" of the first item being paginated. + lastItem # Get the "index" of the last item being paginated. + hasMorePages # Determine if there are more items in the data source. + perPage # Get the number of items shown per page. + hasPages # Determine if there are enough items to split into multiple pages. + currentPage # Determine the current page being paginated. + isEmpty # Determine if the list of items is empty or not. + isNotEmpty # Determine if the list of items is not empty. +} +``` + + +
+ Be sure to type hint on the class (Illuminate\Pagination\LengthAwarePaginator) and not on the interface (Illuminate\Contracts\Pagination\LengthAwarePaginator). The interface itself is not iterable (it does not extend Traversable) and therefore, GraphQLite will refuse to iterate over it. +
+ +### Simple paginator + +Note: if you are using `simplePaginate` instead of `paginate`, you can type hint on the `Illuminate\Pagination\Paginator` class. + +```php +class MyController +{ + /** + * @return Product[] + */ + #[Query] + public function products(): Illuminate\Pagination\Paginator + { + return Product::simplePaginate(15); + } +} +``` + +The behaviour will be exactly the same except you will be missing the `totalCount` and `lastPage` fields. + +## Using GraphQLite with Eloquent efficiently + +In GraphQLite, you are supposed to put a `#[Field]` attribute on each getter. + +Eloquent uses PHP magic properties to expose your database records. +Because Eloquent relies on magic properties, it is quite rare for an Eloquent model to have proper getters and setters. + +So we need to find a workaround. GraphQLite comes with a `#[MagicField]` attribute to help you +working with magic properties. + +```php +#[Type] +#[MagicField(name: "id", outputType: "ID!")] +#[MagicField(name: "name", phpType: "string")] +#[MagicField(name: "categories", phpType: "Category[]")] +class Product extends Model +{ +} +``` + +Please note that since the properties are "magic", they don't have a type. Therefore, +you need to pass either the "outputType" attribute with the GraphQL type matching the property, +or the "phpType" attribute with the PHP type matching the property. + +### Pitfalls to avoid with Eloquent + +When designing relationships in Eloquent, you write a method to expose that relationship this way: + +```php +class User extends Model +{ + /** + * Get the phone record associated with the user. + */ + public function phone() + { + return $this->hasOne('App\Phone'); + } +} +``` + +It would be tempting to put a `#[Field]` attribute on the `phone()` method, but this will not work. Indeed, +the `phone()` method does not return a `App\Phone` object. It is the `phone` magic property that returns it. + +In short: + +
+ This does not work: + +```php +class User extends Model +{ + + #[Field] + public function phone() + { + return $this->hasOne('App\Phone'); + } +} +``` + +
+ +
+ This works: + +```php +#[MagicField(name: "phone", phpType:"App\\Phone")] +class User extends Model +{ + public function phone() + { + return $this->hasOne('App\Phone'); + } +} +``` + +
+ +## Export the schema from the CLI + +The extension comes with a special command: `graphqlite:export-schema`. + +Usage: + +```console +$ ./artisan graphqlite:export-schema --output=schema.graphql +``` + +This will export your GraphQL schema in SDL format. You can use this exported schema to import it in other +tools (like graphql-codegen). \ No newline at end of file diff --git a/website/versioned_docs/version-8.0.0/laravel-package.md b/website/versioned_docs/version-8.0.0/laravel-package.md new file mode 100644 index 0000000000..d7014c2bcc --- /dev/null +++ b/website/versioned_docs/version-8.0.0/laravel-package.md @@ -0,0 +1,153 @@ +--- +id: laravel-package +title: Getting started with Laravel +sidebar_label: Laravel package +--- + +
+ Be advised! This documentation will be removed in a future release. For current and up-to-date Laravel extension specific documentation, please see the Github repository. +
+ +The GraphQLite-Laravel package is compatible with **Laravel 5.7+**, **Laravel 6.x** and **Laravel 7.x**. + +## Installation + +Open a terminal in your current project directory and run: + +```console +$ composer require thecodingmachine/graphqlite-laravel +``` + +If you want to publish the configuration (in order to edit it), run: + +```console +$ php artisan vendor:publish --provider="TheCodingMachine\GraphQLite\Laravel\Providers\GraphQLiteServiceProvider" +``` + +You can then configure the library by editing `config/graphqlite.php`. + +```php title="config/graphqlite.php" + 'App\\Http\\Controllers', + 'types' => 'App\\', + 'debug' => Debug::RETHROW_UNSAFE_EXCEPTIONS, + 'uri' => env('GRAPHQLITE_URI', '/graphql'), + 'middleware' => ['web'], + 'guard' => ['web'], +]; +``` + +The debug parameters are detailed in the [documentation of the Webonyx GraphQL library](https://webonyx.github.io/graphql-php/error-handling/) +which is used internally by GraphQLite. + +## Configuring CSRF protection + +
By default, the /graphql route is placed under web middleware group which requires a +CSRF token.
+ +You have 3 options: + +- Use the `api` middleware +- Disable CSRF for GraphQL routes +- or configure your GraphQL client to pass the `X-CSRF-TOKEN` with every GraphQL query + +### Use the `api` middleware + +If you plan to use graphql for server-to-server connection only, you should probably configure GraphQLite to use the +`api` middleware instead of the `web` middleware: + +```php title="config/graphqlite.php" + ['api'], + 'guard' => ['api'], +]; +``` + +### Disable CSRF for the /graphql route + +If you plan to use graphql from web browsers and if you want to explicitly allow access from external applications +(through CORS headers), you need to disable the CSRF token. + +Simply add `graphql` to `$except` in `app/Http/Middleware/VerifyCsrfToken.php`. + +### Configuring your GraphQL client + +If you are planning to use `graphql` only from your website domain, then the safest way is to keep CSRF enabled and +configure your GraphQL JS client to pass the CSRF headers on any graphql request. + +The way you do this depends on the Javascript GraphQL client you are using. + +Assuming you are using [Apollo](https://www.apollographql.com/docs/link/links/http/), you need to be sure that Apollo passes the token +back to Laravel on every request. + +```js title="Sample Apollo client setup with CSRF support" +import { ApolloClient, ApolloLink, InMemoryCache, HttpLink } from 'apollo-boost'; + +const httpLink = new HttpLink({ uri: 'https://api.example.com/graphql' }); + +const authLink = new ApolloLink((operation, forward) => { + // Retrieve the authorization token from local storage. + const token = localStorage.getItem('auth_token'); + + // Get the XSRF-TOKEN that is set by Laravel on each request + var cookieValue = document.cookie.replace(/(?:(?:^|.*;\s*)XSRF-TOKEN\s*\=\s*([^;]*).*$)|^.*$/, "$1"); + + // Use the setContext method to set the X-CSRF-TOKEN header back. + operation.setContext({ + headers: { + 'X-CSRF-TOKEN': cookieValue + } + }); + + // Call the next link in the middleware chain. + return forward(operation); +}); + +const client = new ApolloClient({ + link: authLink.concat(httpLink), // Chain it with the HttpLink + cache: new InMemoryCache() +}); +``` + +## Adding GraphQL DevTools + +GraphQLite does not include additional GraphQL tooling, such as the GraphiQL editor. +To integrate a web UI to query your GraphQL endpoint with your Laravel installation, +we recommend installing [GraphQL Playground](https://github.com/mll-lab/laravel-graphql-playground) + +```console +$ composer require mll-lab/laravel-graphql-playground +``` + +By default, the playground will be available at `/graphql-playground`. + +Or you can install [Altair GraphQL Client](https://github.com/XKojiMedia/laravel-altair-graphql) + +```console +$ composer require xkojimedia/laravel-altair-graphql +``` + +You can also use any external client with GraphQLite, make sure to point it to the URL defined in the config (`'/graphql'` by default). + +## Troubleshooting HTTP 419 errors + +If HTTP requests to GraphQL endpoint generate responses with the HTTP 419 status code, you have an issue with the configuration of your +CSRF token. Please check again [the paragraph dedicated to CSRF configuration](#configuring-csrf-protection). diff --git a/website/versioned_docs/version-8.0.0/migrating.md b/website/versioned_docs/version-8.0.0/migrating.md new file mode 100644 index 0000000000..2753878265 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/migrating.md @@ -0,0 +1,54 @@ +--- +id: migrating +title: Migrating +sidebar_label: Migrating +--- + +## Migrating from v4.0 to v4.1 + +GraphQLite follows Semantic Versioning. GraphQLite 4.1 is backward compatible with GraphQLite 4.0. See +[semantic versioning](semver.md) for more details. + +There is one exception though: the **ecodev/graphql-upload** package (used to get support for file uploads in GraphQL +input types) is now a "recommended" dependency only. +If you are using GraphQL file uploads, you need to add `ecodev/graphql-upload` to your `composer.json` by running this command: + +```console +$ composer require ecodev/graphql-upload +``` + +## Migrating from v3.0 to v4.0 + +If you are a "regular" GraphQLite user, migration to v4 should be straightforward: + +- Annotations are mostly untouched. The only annotation that is changed is the `#[SourceField]` annotation. + - Check your code for every places where you use the `#[SourceField]` annotation: + - The "id" attribute has been remove (`@SourceField(id=true)`). Instead, use `@SourceField(outputType="ID")` + - The "logged", "right" and "failWith" attributes have been removed (`@SourceField(logged=true)`). + Instead, use the annotations attribute with the same annotations you use for the `#[Field]` annotation: + `@SourceField(annotations={@Logged, @FailWith(null)})` + - If you use magic property and were creating a getter for every magic property (to put a `#[Field]` annotation on it), + you can now replace this getter with a `#[MagicField]` annotation. +- In GraphQLite v3, the default was to hide a field from the schema if a user has no access to it. + In GraphQLite v4, the default is to still show this field, but to throw an error if the user makes a query on it + (this way, the schema is the same for all users). If you want the old mode, use the new + [`@HideIfUnauthorized` annotation](annotations-reference.md#hideifunauthorized-annotation) +- If you are using the Symfony bundle, the Laravel package or the Universal module, you must also upgrade those to 4.0. + These package will take care of the wiring for you. Apart for upgrading the packages, you have nothing to do. +- If you are relying on the `SchemaFactory` to bootstrap GraphQLite, you have nothing to do. + +On the other hand, if you are a power user and if you are wiring GraphQLite services yourself (without using the +`SchemaFactory`) or if you implemented custom "TypeMappers", you will need to adapt your code: + +- The `FieldsBuilderFactory` is gone. Directly instantiate `FieldsBuilder` in v4. +- The `CompositeTypeMapper` class has no more constructor arguments. Use the `addTypeMapper` method to register + type mappers in it. +- The `FieldsBuilder` now accept an extra argument: the `RootTypeMapper` that you need to instantiate accordingly. Take + a look at the `SchemaFactory` class for an example of proper configuration. +- The `HydratorInterface` and all implementations are gone. When returning an input object from a TypeMapper, the object + must now implement the `ResolvableMutableInputInterface` (an input object type that contains its own resolver) + +Note: we strongly recommend to use the Symfony bundle, the Laravel package, the Universal module or the SchemaManager +to bootstrap GraphQLite. Wiring directly GraphQLite classes (like the `FieldsBuilder`) into your container is not recommended, +as the signature of the constructor of those classes may vary from one minor release to another. +Use the `SchemaManager` instead. diff --git a/website/versioned_docs/version-8.0.0/multiple-output-types.mdx b/website/versioned_docs/version-8.0.0/multiple-output-types.mdx new file mode 100644 index 0000000000..2f0d5b1e8f --- /dev/null +++ b/website/versioned_docs/version-8.0.0/multiple-output-types.mdx @@ -0,0 +1,105 @@ +--- +id: multiple-output-types +title: Mapping multiple output types for the same class +sidebar_label: Class with multiple output types +--- + +Available in GraphQLite 4.0+ + +In most cases, you have one PHP class and you want to map it to one GraphQL output type. + +But in very specific cases, you may want to use different GraphQL output type for the same class. +For instance, depending on the context, you might want to prevent the user from accessing some fields of your object. + +To do so, you need to create 2 output types for the same PHP class. You typically do this using the "default" attribute of the `#[Type]` attribute. + +## Example + +Here is an example. Say we are manipulating products. When I query a `Product` details, I want to have access to all fields. +But for some reason, I don't want to expose the price field of a product if I query the list of all products. + +```php +#[Type] +class Product +{ + // ... + + #[Field] + public function getName(): string + { + return $this->name; + } + + #[Field] + public function getPrice(): ?float + { + return $this->price; + } +} +``` + +The `Product` class is declaring a classic GraphQL output type named "Product". + +```php +#[Type(class: Product::class, name: "LimitedProduct", default: false)] +#[SourceField(name: "name")] +class LimitedProductType +{ + // ... +} +``` + +The `LimitedProductType` also declares an ["external" type](external-type-declaration.mdx) mapping the `Product` class. +But pay special attention to the `#[Type]` attribute. + +First of all, we specify `name="LimitedProduct"`. This is useful to avoid having colliding names with the "Product" GraphQL output type +that is already declared. + +Then, we specify `default=false`. This means that by default, the `Product` class should not be mapped to the `LimitedProductType`. +This type will only be used when we explicitly request it. + +Finally, we can write our requests: + +```php +class ProductController +{ + /** + * This field will use the default type. + */ + #[Field] + public function getProduct(int $id): Product { /* ... */ } + + /** + * Because we use the "outputType" attribute, this field will use the other type. + * + * @return Product[] + */ + #[Field(outputType: "[LimitedProduct!]!")] + public function getProducts(): array { /* ... */ } +} +``` + +Notice how the "outputType" attribute is used in the `#[Field]` attribute to force the output type. + +Is a result, when the end user calls the `product` query, we will have the possibility to fetch the `name` and `price` fields, +but if he calls the `products` query, each product in the list will have a `name` field but no `price` field. We managed +to successfully expose a different set of fields based on the query context. + +## Extending a non-default type + +If you want to extend a type using the `#[ExtendType]` attribute and if this type is declared as non-default, +you need to target the type by name instead of by class. + +So instead of writing: + +```php +#[ExtendType(class: Product::class)] +``` + +you will write: + +```php +#[ExtendType(name: "LimitedProduct")] +``` + +Notice how we use the "name" attribute instead of the "class" attribute in the `#[ExtendType]` attribute. diff --git a/website/versioned_docs/version-8.0.0/mutations.mdx b/website/versioned_docs/version-8.0.0/mutations.mdx new file mode 100644 index 0000000000..614705e6f6 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/mutations.mdx @@ -0,0 +1,27 @@ +--- +id: mutations +title: Mutations +sidebar_label: Mutations +--- + +In GraphQLite, mutations are created [like queries](queries.mdx). + +To create a mutation, you must annotate a method in a controller with the `#[Mutation]` attribute. + +For instance: + +```php +namespace App\Controller; + +use TheCodingMachine\GraphQLite\Annotations\Mutation; + +class ProductController +{ + #[Mutation] + public function saveProduct(int $id, string $name, ?float $price = null): Product + { + // Some code that saves a product. + } +} +``` + diff --git a/website/versioned_docs/version-8.0.0/operation-complexity.md b/website/versioned_docs/version-8.0.0/operation-complexity.md new file mode 100644 index 0000000000..bf946721ee --- /dev/null +++ b/website/versioned_docs/version-8.0.0/operation-complexity.md @@ -0,0 +1,223 @@ +--- +id: operation-complexity +title: Operation complexity +sidebar_label: Operation complexity +--- + +At some point you may find yourself receiving queries with an insane amount of requested +fields or items, all at once. Usually, it's not a good thing, so you may want to somehow +limit the amount of requests or their individual complexity. + +## Query depth + +The simplest way to limit complexity is to limit the max query depth. `webonyx/graphql-php`, +which GraphQLite relies on, [has this built in](https://webonyx.github.io/graphql-php/security/#limiting-query-depth). +To use it, you may use `addValidationRule` when building your PSR15 middleware: + +```php +$builder = new Psr15GraphQLMiddlewareBuilder($schema); + +$builder->addValidationRule(new \GraphQL\Validator\Rules\QueryDepth(7)); +``` + +Although this works for simple cases, this doesn't prevent requesting an excessive amount +of fields on the depth of under 7, nor does it prevent requesting too many nodes in paginated lists. +This is where automatic query complexity comes to save us. + +## Static request analysis + +The operation complexity analyzer is a useful tool to make your API secure. The operation +complexity analyzer assigns by default every field a complexity of `1`. The complexity of +all fields in one of the operations of a GraphQL request is not allowed to be greater +than the maximum permitted operation complexity. + +This sounds fairly simple at first, but the more you think about this, the more you +wonder if that is so. Does every field have the same complexity? + +In a data graph, not every field is the same. We have fields that fetch data that are +more expensive than fields that just complete already resolved data. + +```graphql +type Query { + books(take: Int = 10): [Book] +} + +type Book { + title + author: Author +} + +type Author { + name +} +``` + +In the above example executing the `books` field on the `Query` type might go to the +database and fetch the `Book`. This means that the cost of the `books` field is +probably higher than the cost of the `title` field. The cost of the title field +might be the impact on the memory and to the transport. For `title`, the default +cost of `1` os OK. But for `books`, we might want to go with a higher cost of `10` +since we are getting a list of books from our database. + +Moreover, we have the field `author` on the book, which might go to the database +as well to fetch the `Author` object. Since we are only fetching a single item here, +we might want to apply a cost of `5` to this field. + +```php +class Controller { + /** + * @return Book[] + */ + #[Query] + #[Cost(complexity: 10)] + public function books(int $take = 10): array {} +} + +#[Type] +class Book { + #[Field] + public string $title; + + #[Field] + #[Cost(complexity: 5)] + public Author $author; +} + +#[Type] +class Author { + #[Field] + public string $name; +} +``` + +If we run the following query against our data graph, we will come up with the cost of `11`. + +```graphql +query { + books { + title + } +} +``` + +When drilling in further, a cost of `17` occurs. + +```graphql +query { + books { + title + author { + name + } + } +} +``` + +This kind of analysis is entirely static and could just be done by inspecting the +query syntax tree. The impact on the overall execution performance is very low. +But with this static approach, we do have a very rough idea of the performance. +Is it correct to apply always a cost of `10` even though we might get one or one +hundred books back? + +## Full request analysis + +The operation complexity analyzer can also take arguments into account when analyzing operation complexity. + +If we look at our data graph, we can see that the `books` field actually has an argument +that defines how many books are returned. The `take` argument, in this case, specifies +the maximum books that the field will return. + +When measuring the field\`s impact, we can take the argument `take` into account as a +multiplier of our cost. This means we might want to lower the cost to `5` since now we +get a more fine-grained cost calculation by multiplying the complexity +of the field with the `take` argument. + +```php +class Controller { + /** + * @return Book[] + */ + #[Query] + #[Cost(complexity: 5, multipliers: ['take'], defaultMultiplier: 200)] + public function books(?int $take = 10): array {} +} + +#[Type] +class Book { + #[Field] + public string $title; + + #[Field] + #[Cost(complexity: 5)] + public Author $author; +} + +#[Type] +class Author { + #[Field] + public string $name; +} +``` + +With the multiplier in place, we now get a cost of `60` for the request since the multiplier +is applied to the books field and the child fields' cost. If multiple multipliers are specified, +the cost will be multiplied by each of the fields. + +Cost calculation: `10 * (5 + 1)` + +```graphql +query { + books { + title + } +} +``` + +When drilling in further, the cost will go up to `240` since we are now pulling twice as much books and also their authors. + +Cost calculation: `20 * (5 + 1 + 5 + 1)` + +```graphql +query { + books(take: 20) { + title + author { + name + } + } +} +``` + +Notice the nullable `$take` parameter. This might come in handy if `take: null` means "get all items", +but that would also mean that the overall complexity would only be `1 + 5 + 1 + 5 + 1 = 11`, +when in fact that would be a very costly query to execute. + +If all of the multiplier fields are either `null` or missing (and don't have default values), +`defaultMultiplier` is used: + +Cost calculation: `200 * (5 + 1 + 5 + 1)` + +```graphql +query { + books(take: null) { + title + author { + name + } + } +} +``` + +## Setup + +As with query depth, automatic query complexity is configured through PSR15 middleware: + +```php +$builder = new Psr15GraphQLMiddlewareBuilder($schema); + +// Total query cost cannot exceed 1000 points +$builder->limitQueryComplexity(1000); +``` + +Beware that introspection queries would also be limited in complexity. A full introspection +query sits at around `107` points, so we recommend a minimum of `150` for query complexity limit. diff --git a/website/versioned_docs/version-8.0.0/other-frameworks.mdx b/website/versioned_docs/version-8.0.0/other-frameworks.mdx new file mode 100644 index 0000000000..e2684c0e9f --- /dev/null +++ b/website/versioned_docs/version-8.0.0/other-frameworks.mdx @@ -0,0 +1,326 @@ +--- +id: other-frameworks +title: Getting started with any framework +sidebar_label: "Other frameworks / No framework" +--- + +## Installation + +Open a terminal in your current project directory and run: + +```console +$ composer require thecodingmachine/graphqlite +``` + +## Requirements + +In order to bootstrap GraphQLite, you will need: + +- A PSR-11 compatible container +- A PSR-16 cache + +Additionally, you will have to route the HTTP requests to the underlying GraphQL library. + +GraphQLite relies on the [webonyx/graphql-php](http://webonyx.github.io/graphql-php/) library internally. +This library plays well with PSR-7 requests and we also provide a [PSR-15 middleware](#psr-15-middleware). + +## Integration + +Webonyx/graphql-php library requires a [Schema](https://webonyx.github.io/graphql-php/type-system/schema/) in order to resolve +GraphQL queries. We provide a `SchemaFactory` class to create such a schema: + +```php +use TheCodingMachine\GraphQLite\SchemaFactory; + +// $cache is a PSR-16 compatible cache +// $container is a PSR-11 compatible container +$factory = new SchemaFactory($cache, $container); +$factory->addNamespace('App'); + +$schema = $factory->createSchema(); +``` + +You can now use this schema with [Webonyx GraphQL facade](https://webonyx.github.io/graphql-php/getting-started/#hello-world) +or the [StandardServer class](https://webonyx.github.io/graphql-php/executing-queries/#using-server). + +The `SchemaFactory` class also comes with a number of methods that you can use to customize your GraphQLite settings. + +```php +// Configure an authentication service (to resolve the #[Logged] attributes). +$factory->setAuthenticationService(new VoidAuthenticationService()); +// Configure an authorization service (to resolve the #[Right] attributes). +$factory->setAuthorizationService(new VoidAuthorizationService()); +// Change the naming convention of GraphQL types globally. +$factory->setNamingStrategy(new NamingStrategy()); +// Add a custom type mapper. +$factory->addTypeMapper($typeMapper); +// Add a custom type mapper using a factory to create it. +// Type mapper factories are useful if you need to inject the "recursive type mapper" into your type mapper constructor. +$factory->addTypeMapperFactory($typeMapperFactory); +// Add a root type mapper. +$factory->addRootTypeMapper($rootTypeMapper); +// Add a parameter mapper. +$factory->addParameterMapper($parameterMapper); +// Add a query provider. These are used to find queries and mutations in the application. +$factory->addQueryProvider($queryProvider); +// Add a query provider using a factory to create it. +// Query provider factories are useful if you need to inject the "fields builder" into your query provider constructor. +$factory->addQueryProviderFactory($queryProviderFactory); +// Set a default InputType validator service to handle validation on all `Input` annotated types +$factory->setInputTypeValidator($validator); +// Add custom options to the Webonyx underlying Schema. +$factory->setSchemaConfig($schemaConfig); +// Configures the time-to-live for the GraphQLite cache. Defaults to 2 seconds in dev mode. +$factory->setGlobTtl(2); +// Enables prod-mode (cache settings optimized for best performance). +// This is a shortcut for `$schemaFactory->setGlobTtl(null)` +$factory->prodMode(); +// Enables dev-mode (this is the default mode: cache settings optimized for best developer experience). +// This is a shortcut for `$schemaFactory->setGlobTtl(2)` +$factory->devMode(); +``` + +### GraphQLite context + +Webonyx allows you pass a "context" object when running a query. +For some GraphQLite features to work (namely: the prefetch feature), GraphQLite needs you to initialize the Webonyx context +with an instance of the `TheCodingMachine\GraphQLite\Context\Context` class. + +For instance: + +```php +use TheCodingMachine\GraphQLite\Context\Context; + +$result = GraphQL::executeQuery($schema, $query, null, new Context(), $variableValues); +``` + +### Disabling autoloading + +GraphQLite uses `kcs/class-finder` to find all classes that have GraphQLite attributes. By default, it uses +autoloading under the hood. But if you have an older codebase that contains classes with incorrect or missing +namespaces, you may need to use `include_once` instead. To do so, you can overwrite the finder using `setFinder()`: + +```php +use Kcs\ClassFinder\Finder\ComposerFinder; +use TheCodingMachine\GraphQLite\SchemaFactory; + +$factory = new SchemaFactory($cache, $container); +$factory->addNamespace('App') + ->setFinder( + (new ComposerFinder())->useAutoloading(false) + ); + +$schema = $factory->createSchema(); +``` + +## Minimal example + +The smallest working example using no framework is: + +```php +addNamespace('App'); + +$schema = $factory->createSchema(); + +$rawInput = file_get_contents('php://input'); +$input = json_decode($rawInput, true); +$query = $input['query']; +$variableValues = isset($input['variables']) ? $input['variables'] : null; + +$result = GraphQL::executeQuery($schema, $query, null, new Context(), $variableValues); +$output = $result->toArray(); + +header('Content-Type: application/json'); +echo json_encode($output); +``` + +## PSR-15 Middleware + +When using a framework, you will need a way to route your HTTP requests to the `webonyx/graphql-php` library. + +If the framework you are using is compatible with PSR-15 (like Slim PHP or Zend-Expressive / Laminas), GraphQLite +comes with a PSR-15 middleware out of the box. + +In order to get an instance of this middleware, you can use the `Psr15GraphQLMiddlewareBuilder` builder class: + +```php +// $schema is an instance of the GraphQL schema returned by SchemaFactory::createSchema (see previous chapter) +$builder = new Psr15GraphQLMiddlewareBuilder($schema); + +$middleware = $builder->createMiddleware(); + +// You can now inject your middleware in your favorite PSR-15 compatible framework. +// For instance: +$zendMiddlewarePipe->pipe($middleware); +``` + +The builder offers a number of setters to modify its behaviour: + +```php +$builder->setUrl("/graphql"); // Modify the URL endpoint (defaults to /graphql) + +$config = $builder->getConfig(); // Returns a Webonyx ServerConfig object. +// Define your own formatter and error handlers for Webonyx. +$config->setErrorFormatter([ExceptionHandler::class, 'errorFormatter']); +$config->setErrorsHandler([ExceptionHandler::class, 'errorHandler']); + +$builder->setConfig($config); + +$builder->setResponseFactory(new ResponseFactory()); // Set a PSR-18 ResponseFactory (not needed if you are using zend-framework/zend-diactoros ^2 +$builder->setStreamFactory(new StreamFactory()); // Set a PSR-18 StreamFactory (not needed if you are using zend-framework/zend-diactoros ^2 +$builder->setHttpCodeDecider(new HttpCodeDecider()); // Set a class in charge of deciding the HTTP status code based on the response. + +// Configure the server to use Apollo automatic persisted queries with given cache and an optional time-to-live. +// See https://www.apollographql.com/docs/apollo-server/performance/apq/ +$builder->useAutomaticPersistedQueries($cache, new DateInterval('PT1H')); +``` + +### Example + +In this example, we will focus on getting a working version of GraphQLite using: + +- [Laminas Stratigility](https://docs.laminas.dev/laminas-stratigility/) as a PSR-15 server +- `mouf/picotainer` (a micro-container) for the PSR-11 container +- `symfony/cache ` for the PSR-16 cache + +The choice of the libraries is really up to you. You can adapt it based on your needs. + +```json title="composer.json" +{ + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "require": { + "thecodingmachine/graphqlite": "^4", + "laminas/laminas-diactoros": "^2", + "laminas/laminas-stratigility": "^3", + "laminas/laminas-httphandlerrunner": "^2", + "mouf/picotainer": "^1.1", + "symfony/cache": "^4.2" + }, + "minimum-stability": "dev", + "prefer-stable": true +} +``` + +```php title="index.php" +get(MiddlewarePipe::class), + new SapiStreamEmitter(), + $serverRequestFactory, + $errorResponseGenerator +); +$runner->run(); +``` + +Here we are initializing a Laminas `RequestHandler` (it receives requests) and we pass it to a Laminas Stratigility `MiddlewarePipe`. +This `MiddlewarePipe` comes from the container declared in the `config/container.php` file: + +```php title="config/container.php" + function(ContainerInterface $container) { + $pipe = new MiddlewarePipe(); + $pipe->pipe($container->get(WebonyxGraphqlMiddleware::class)); + return $pipe; + }, + // The WebonyxGraphqlMiddleware is a PSR-15 compatible + // middleware that exposes Webonyx schemas. + WebonyxGraphqlMiddleware::class => function(ContainerInterface $container) { + $builder = new Psr15GraphQLMiddlewareBuilder($container->get(Schema::class)); + return $builder->createMiddleware(); + }, + CacheInterface::class => function() { + // Any PSR-16 cache should work - APCu is recommended for good + // performance, but it requires that module to be enabled. For + // small scale testing with zero dependencies, FilesystemCache + // can be used instead. + return new ApcuCache(); + }, + Schema::class => function(ContainerInterface $container) { + // The magic happens here. We create a schema using GraphQLite SchemaFactory. + $factory = new SchemaFactory($container->get(CacheInterface::class), $container); + $factory->addNamespace('App'); + return $factory->createSchema(); + } +]); +``` + +Now, we need to add a first query and therefore create a controller. +The application will look into the `App\Controllers` namespace for GraphQLite controllers. + +It assumes that the container has an entry whose name is the controller's fully qualified class name. + +```php title="src/Controllers/MyController.php" +namespace App\Controllers; + +use TheCodingMachine\GraphQLite\Annotations\Query; + +class MyController +{ + #[Query] + public function hello(string $name): string + { + return 'Hello '.$name; + } +} +``` + +```php title="config/container.php" +use App\Controllers\MyController; + +return new Picotainer([ + // ... + + // We declare the controller in the container. + MyController::class => function() { + return new MyController(); + }, +]); +``` + +And we are done! You can now test your query using your favorite GraphQL client. diff --git a/website/versioned_docs/version-8.0.0/pagination.mdx b/website/versioned_docs/version-8.0.0/pagination.mdx new file mode 100644 index 0000000000..44ad427c53 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/pagination.mdx @@ -0,0 +1,65 @@ +--- +id: pagination +title: Paginating large result sets +sidebar_label: Pagination +--- + +It is quite common to have to paginate over large result sets. + +GraphQLite offers a simple way to do that using [Porpaginas](https://github.com/beberlei/porpaginas). + +Porpaginas is a set of PHP interfaces that can be implemented by result iterators. It comes with a native support for +PHP arrays, Doctrine and [TDBM](https://thecodingmachine.github.io/tdbm/doc/limit_offset_resultset.html). + +
If you are a Laravel user, Eloquent does not come with a Porpaginas +iterator. However, the GraphQLite Laravel bundle comes with its own pagination system.
+ +## Installation + +You will need to install the [Porpaginas](https://github.com/beberlei/porpaginas) library to benefit from this feature. + +```bash +$ composer require beberlei/porpaginas +``` + +## Usage + +In your query, simply return a class that implements `Porpaginas\Result`: + +```php +class MyController +{ + /** + * @return Product[] + */ + #[Query] + public function products(): Porpaginas\Result + { + // Some code that returns a list of products + + // If you are using Doctrine, something like: + return new Porpaginas\Doctrine\ORM\ORMQueryResult($doctrineQuery); + } +} +``` + +Notice that: + +- the method return type MUST BE `Porpaginas\Result` or a class implementing `Porpaginas\Result` +- you MUST add a `@return` statement to help GraphQLite find the type of the list + +Once this is done, you can paginate directly from your GraphQL query: + +```graphql +products { + items(limit: 10, offset: 20) { + id + name + } + count +} +``` + +Results are wrapped into an item field. You can use the "limit" and "offset" parameters to apply pagination automatically. + +The "count" field returns the **total count** of items. diff --git a/website/versioned_docs/version-8.0.0/prefetch-method.mdx b/website/versioned_docs/version-8.0.0/prefetch-method.mdx new file mode 100644 index 0000000000..a12cb50891 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/prefetch-method.mdx @@ -0,0 +1,108 @@ +--- +id: prefetch-method +title: Prefetching records +sidebar_label: Prefetching records +--- + +## The problem + +GraphQL naive implementations often suffer from the "N+1" problem. + +Consider a request where a user attached to a post must be returned: + +```graphql +{ + posts { + id + user { + id + } + } +} +``` + +A naive implementation will do this: + +- 1 query to fetch the list of posts +- 1 query per post to fetch the user + +Assuming we have "N" posts, we will make "N+1" queries. + +There are several ways to fix this problem. +Assuming you are using a relational database, one solution is to try to look +ahead and perform only one query with a JOIN between "posts" and "users". +This method is described in the ["analyzing the query plan" documentation](query-plan.mdx). + +But this can be difficult to implement. This is also only useful for relational databases. If your data comes from a +NoSQL database or from the cache, this will not help. + +Instead, GraphQLite offers an easier to implement solution: the ability to fetch all fields from a given type at once. + +## The "prefetch" method + +```php +#[Type] +class PostType { + /** + * @param mixed $prefetchedUsers + * @return User + */ + #[Field] + public function getUser(#[Prefetch("prefetchUsers")] $prefetchedUsers): User + { + // This method will receive the $prefetchedUsers as first argument. This is the return value of the "prefetchUsers" method below. + // Using this prefetched list, it should be easy to map it to the post + } + + /** + * @param Post[] $posts + * @return mixed + */ + public static function prefetchUsers(iterable $posts) + { + // This function is called only once per GraphQL request + // with the list of posts. You can fetch the list of users + // associated with this posts in a single request, + // for instance using a "IN" query in SQL or a multi-fetch + // in your cache back-end. + } +} +``` + +When a `#[Prefetch]` attribute is detected on a parameter of `#[Field]` attribute, the method is called automatically. +The prefetch callable must be one of the following: + - a static method in the same class: `#[Prefetch('prefetchMethod')]` + - a static method in a different class: `#[Prefetch([OtherClass::class, 'prefetchMethod')]` + - a non-static method in a different class, resolvable through the container: `#[Prefetch([OtherService::class, 'prefetchMethod'])]` +The first argument of the method is always an array of instances of the main type. It can return absolutely anything (mixed). + +## Input arguments + +Field arguments can be set either on the `#[Field]` annotated method OR/AND on the prefetch methods. + +For instance: + +```php +#[Type] +class PostType { + /** + * @param mixed $prefetchedComments + * @return Comment[] + */ + #[Field] + public function getComments(#[Prefetch("prefetchComments")] $prefetchedComments): array + { + // ... + } + + /** + * @param Post[] $posts + * @return mixed + */ + public static function prefetchComments(iterable $posts, bool $hideSpam, int $filterByScore) + { + // Parameters passed after the first parameter (hideSpam, filterByScore...) are automatically exposed + // as GraphQL arguments for the "comments" field. + } +} +``` diff --git a/website/versioned_docs/version-8.0.0/queries.mdx b/website/versioned_docs/version-8.0.0/queries.mdx new file mode 100644 index 0000000000..138e4812eb --- /dev/null +++ b/website/versioned_docs/version-8.0.0/queries.mdx @@ -0,0 +1,138 @@ +--- +id: queries +title: Queries +sidebar_label: Queries +--- + +In GraphQLite, GraphQL queries are created by writing methods in *controller* classes. + +Those classes must be in the controllers namespaces which has been defined when you configured GraphQLite. +For instance, in Symfony, the controllers namespace is `App\Controller` by default. + +## Simple query + +In a controller class, each query method must be annotated with the `#[Query]` attribute. For instance: + +```php +namespace App\Controller; + +use TheCodingMachine\GraphQLite\Annotations\Query; + +class MyController +{ + #[Query] + public function hello(string $name): string + { + return 'Hello ' . $name; + } +} +``` + +This query is equivalent to the following [GraphQL type language](https://graphql.org/learn/schema/#type-language): + +```graphql +Type Query { + hello(name: String!): String! +} +``` + +As you can see, GraphQLite will automatically do the mapping between PHP types and GraphQL types. + +
Heads up! If you are not using a framework with an autowiring container (like Symfony or Laravel), please be aware that the MyController class must exist in the container of your application. Furthermore, the identifier of the controller in the container MUST be the fully qualified class name of controller.
+ +## About attributes + +GraphQLite relies a lot on attributes. + +It supports the new PHP 8 attributes (`#[Query]`), the "Doctrine annotations" style (`#[Query]`) was dropped. + +## Testing the query + +The default GraphQL endpoint is `/graphql`. + +The easiest way to test a GraphQL endpoint is to use [GraphiQL](https://github.com/graphql/graphiql) or +[Altair](https://altair.sirmuel.design/) clients (they are available as Chrome or Firefox plugins) + +
+ If you are using the Symfony bundle, GraphiQL is also directly embedded.
+ Simply head to http://[path-to-my-app]/graphiql +
+ +Here a query using our simple *Hello World* example: + +![](/img/query1.png) + +## Query with a type + +So far, we simply declared a query. But we did not yet declare a type. + +Let's assume you want to return a product: + +```php +namespace App\Controller; + +use TheCodingMachine\GraphQLite\Annotations\Query; + +class ProductController +{ + #[Query] + public function product(string $id): Product + { + // Some code that looks for a product and returns it. + } +} +``` + + +As the `Product` class is not a scalar type, you must tell GraphQLite how to handle it: + +```php +namespace App\Entities; + +use TheCodingMachine\GraphQLite\Annotations\Field; +use TheCodingMachine\GraphQLite\Annotations\Type; + +#[Type] +class Product +{ + // ... + + #[Field] + public function getName(): string + { + return $this->name; + } + + #[Field] + public function getPrice(): ?float + { + return $this->price; + } +} +``` + +The `#[Type]` attribute is used to inform GraphQLite that the `Product` class is a GraphQL type. + +The `#[Field]` attribute is used to define the GraphQL fields. This attribute must be put on a **public method**. + +The `Product` class must be in one of the *types* namespaces. As for *controller* classes, you configured this namespace when you installed +GraphQLite. By default, in Symfony, the allowed types namespaces are `App\Entity` and `App\Types`. + +This query is equivalent to the following [GraphQL type language](https://graphql.org/learn/schema/#type-language): + +```graphql +Type Product { + name: String! + price: Float +} +``` + +
+

If you are used to Domain driven design, you probably + realize that the Product class is part of your domain.

+

GraphQL attributes are adding some serialization logic that is out of scope of the domain. + These are just attributes and for most project, this is the fastest and easiest route.

+

If you feel that GraphQL attributes do not belong to the domain, or if you cannot modify the class + directly (maybe because it is part of a third party library), there is another way to create types without annotating + the domain class. We will explore that in the next chapter.

+
diff --git a/website/versioned_docs/version-8.0.0/query-plan.mdx b/website/versioned_docs/version-8.0.0/query-plan.mdx new file mode 100644 index 0000000000..094b77c55e --- /dev/null +++ b/website/versioned_docs/version-8.0.0/query-plan.mdx @@ -0,0 +1,70 @@ +--- +id: query-plan +title: Query plan +sidebar_label: Query plan +--- + +## The problem + +GraphQL naive implementations often suffer from the "N+1" problem. + +Let's have a look at the following query: + +```graphql +{ + products { + name + manufacturer { + name + } + } +} +``` + +A naive implementation will do this: + +- 1 query to fetch the list of products +- 1 query per product to fetch the manufacturer + +Assuming we have "N" products, we will make "N+1" queries. + +There are several ways to fix this problem. Assuming you are using a relational database, one solution is to try to look +ahead and perform only one query with a JOIN between "products" and "manufacturers". + +But how do I know if I should make the JOIN between "products" and "manufacturers" or not? I need to know ahead +of time. + +With GraphQLite, you can answer this question by tapping into the `ResolveInfo` object. + +## Fetching the query plan + +Available in GraphQLite 4.0+ + +```php +use GraphQL\Type\Definition\ResolveInfo; + +class ProductsController +{ + /** + * @return Product[] + */ + #[Query] + public function products(ResolveInfo $info): array + { + if (isset($info->getFieldSelection()['manufacturer']) { + // Let's perform a request with a JOIN on manufacturer + } else { + // Let's perform a request without a JOIN on manufacturer + } + // ... + } +} +``` + +`ResolveInfo` is a class provided by Webonyx/GraphQL-PHP (the low-level GraphQL library used by GraphQLite). +It contains info about the query and what fields are requested. Using `ResolveInfo::getFieldSelection` you can analyze the query +and decide whether you should perform additional "JOINS" in your query or not. + +
As of the writing of this documentation, the ResolveInfo class is useful but somewhat limited. +The next version of Webonyx/GraphQL-PHP will add a "query plan" +that allows a deeper analysis of the query.
diff --git a/website/versioned_docs/version-8.0.0/semver.md b/website/versioned_docs/version-8.0.0/semver.md new file mode 100644 index 0000000000..12931f26a5 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/semver.md @@ -0,0 +1,45 @@ +--- +id: semver +title: Our backward compatibility promise +sidebar_label: Semantic versioning +--- + +Ensuring smooth upgrades of your project is a priority. That's why we promise you backward compatibility (BC) for all minor GraphQLite releases. You probably recognize this strategy as [Semantic Versioning](https://semver.org/). In short, Semantic Versioning means that only major releases (such as 4.0, 5.0 etc.) are allowed to break backward compatibility. Minor releases (such as 4.0, 4.1 etc.) may introduce new features, but must do so without breaking the existing API of that release branch (4.x in the previous example). + +But sometimes, a new feature is not quite "dry" and we need a bit of time to find the perfect API. +In such cases, we prefer to gather feedback from real-world usage, adapt the API, or remove it altogether. +Doing so is not possible with a no BC-break approach. + +To avoid being bound to our backward compatibility promise, such features can be marked as **unstable** or **experimental** and their classes and methods are marked with the `@unstable` or `@experimental` tag. + +`@unstable` or `@experimental` classes / methods will **not break** in a patch release, but *may be broken* in a minor version. + +As a rule of thumb: + +- If you are a GraphQLite user (using GraphQLite mainly through its annotations), we guarantee strict semantic versioning +- If you are extending GraphQLite features (if you are developing custom annotations, or if you are developing a GraphQlite integration + with a framework...), be sure to check the tags. + +Said otherwise: + +- If you are a GraphQLite user, in your `composer.json`, target a major version: + + ```json + { + "require": { + "thecodingmachine/graphqlite": "^4" + } + } + ``` + +- If you are extending the GraphQLite ecosystem, in your `composer.json`, target a minor version: + + ```json + { + "require": { + "thecodingmachine/graphqlite": "~4.1.0" + } + } + ``` + +Finally, classes / methods annotated with the `@internal` annotation are not meant to be used in your code or third-party library. They are meant for GraphQLite internal usage and they may break anytime. Do not use those directly. diff --git a/website/versioned_docs/version-8.0.0/subscriptions.mdx b/website/versioned_docs/version-8.0.0/subscriptions.mdx new file mode 100644 index 0000000000..bcfd118aac --- /dev/null +++ b/website/versioned_docs/version-8.0.0/subscriptions.mdx @@ -0,0 +1,53 @@ +--- +id: subscriptions +title: Subscriptions +sidebar_label: Subscriptions +--- + +In GraphQLite, subscriptions are created [like queries](queries.mdx) or [mutations](mutations.mdx). + +To create a subscription, you must annotate a method in a controller with the `#[Subscription]` attribute. + +For instance: + +```php +namespace App\Controller; + +use TheCodingMachine\GraphQLite\Annotations\Mutation; + +class ProductController +{ + #[Subscription(outputType: 'Product')] + public function productAdded(?ID $categoryId = null): void + { + // Some code that sets up any connections, stores the subscription details, etc. + } +} +``` + +As you will notice in the above example, we're returning `void`. In general, this is probably the +correct return type. + +You could, however, type the `Product` as the return type of the method, instead +of using the `outputType` argument on the `#[Subscription]` attribute. This means you +would have to return an instance of `Product` from the method though. One exception here, is if +you intend to use PHP for your long-running streaming process, you could block the process inside +the controller and basically never return anything from the method, just terminating the +connection/stream when it breaks, or when the client disconnects. + +Most implementations will want to offload the actual real-time streaming connection to a better suited +technology, like SSE (server-sent events), WebSockets, etc. GraphQLite does not make any assumptions +here. Therefore, it's most practical to return `void` from the controller method. Since GraphQL +is a strictly typed spec, we cannot return anything other than the defined `outputType` from the request. +That would be a violation of the GraphQL specification. Returning `void`, which is translated to `null` +in the GraphQL response body, allows for us to complete the request and terminate the PHP process. + +We recommend using response headers to pass back any necessary information realted to the subscription. +This might be a subscription ID, a streaming server URL to connect to, or whatever you need to pass +back to the client. + +
+ In the future, it may make sense to implement streaming servers directly into GraphQLite, especially + as PHP progresses with async and parallel processing. At this time, we might consider returning a + `Generator` (or `Fiber`) from the controller method. +
diff --git a/website/versioned_docs/version-8.0.0/symfony-bundle-advanced.mdx b/website/versioned_docs/version-8.0.0/symfony-bundle-advanced.mdx new file mode 100644 index 0000000000..c34c6d7628 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/symfony-bundle-advanced.mdx @@ -0,0 +1,169 @@ +--- +id: symfony-bundle-advanced +title: "Symfony bundle: advanced usage" +sidebar_label: Symfony specific features +--- + +
+ Be advised! This documentation will be removed in a future release. For current and up-to-date Symfony bundle specific documentation, please see the Github repository. +
+ +The Symfony bundle comes with a number of features to ease the integration of GraphQLite in Symfony. + +## Login and logout + +Out of the box, the GraphQLite bundle will expose a "login" and a "logout" mutation as well +as a "me" query (that returns the current user). + +If you need to customize this behaviour, you can edit the "graphqlite.security" configuration key. + +```yaml +graphqlite: + security: + enable_login: auto # Default setting + enable_me: auto # Default setting +``` + +By default, GraphQLite will enable "login" and "logout" mutations and the "me" query if the following conditions are met: + +- the "security" bundle is installed and configured (with a security provider and encoder) +- the "session" support is enabled (via the "framework.session.enabled" key). + +```yaml +graphqlite: + security: + enable_login: on +``` + +By settings `enable_login=on`, you are stating that you explicitly want the login/logout mutations. +If one of the dependencies is missing, an exception is thrown (unlike in default mode where the mutations +are silently discarded). + +```yaml +graphqlite: + security: + enable_login: off +``` + +Use the `enable_login=off` to disable the mutations. + +```yaml +graphqlite: + security: + firewall_name: main # default value +``` + +By default, GraphQLite assumes that your firewall name is "main". This is the default value used in the +Symfony security bundle so it is likely the value you are using. If for some reason you want to use +another firewall, configure the name with `graphqlite.security.firewall_name`. + +## Schema and request security + +You can disable the introspection of your GraphQL API (for instance in production mode) using +the `introspection` configuration properties. + +```yaml +graphqlite: + security: + introspection: false +``` + + +You can set the maximum complexity and depth of your GraphQL queries using the `maximum_query_complexity` +and `maximum_query_depth` configuration properties + +```yaml +graphqlite: + security: + maximum_query_complexity: 314 + maximum_query_depth: 42 +``` + +### Login using the "login" mutation + +The mutation below will log-in a user: + +```graphql +mutation login { + login(userName:"foo", password:"bar") { + userName + roles + } +} +``` + +### Get the current user with the "me" query + +Retrieving the current user is easy with the "me" query: + +```graphql +{ + me { + userName + roles + } +} +``` + +In Symfony, user objects implement `Symfony\Component\Security\Core\User\UserInterface`. +This interface is automatically mapped to a type with 2 fields: + +- `userName: String!` +- `roles: [String!]!` + +If you want to get more fields, just add the `#[Type]` attribute to your user class: + +```php +#[Type] +class User implements UserInterface +{ + #[Field] + public function getEmail() : string + { + // ... + } + +} +``` + +You can now query this field using an [inline fragment](https://graphql.org/learn/queries/#inline-fragments): + +```graphql +{ + me { + userName + roles + ... on User { + email + } + } +} +``` + +### Logout using the "logout" mutation + +Use the "logout" mutation to log a user out + +```graphql +mutation logout { + logout +} +``` + +## Injecting the Request + +You can inject the Symfony Request object in any query/mutation/field. + +Most of the time, getting the request object is irrelevant. Indeed, it is GraphQLite's job to parse this request and +manage it for you. Sometimes yet, fetching the request can be needed. In those cases, simply type-hint on the request +in any parameter of your query/mutation/field. + +```php +use Symfony\Component\HttpFoundation\Request; + +#[Query] +public function getUser(int $id, Request $request): User +{ + // The $request object contains the Symfony Request. +} +``` \ No newline at end of file diff --git a/website/versioned_docs/version-8.0.0/symfony-bundle.md b/website/versioned_docs/version-8.0.0/symfony-bundle.md new file mode 100644 index 0000000000..99822eeddd --- /dev/null +++ b/website/versioned_docs/version-8.0.0/symfony-bundle.md @@ -0,0 +1,121 @@ +--- +id: symfony-bundle +title: Getting started with Symfony +sidebar_label: Symfony bundle +--- + +
+ Be advised! This documentation will be removed in a future release. For current and up-to-date Symfony bundle specific documentation, please see the Github repository. +
+ +The GraphQLite bundle is compatible with **Symfony 4.x** and **Symfony 5.x**. + +## Applications that use Symfony Flex + +Open a command console, enter your project directory and execute: + +```console +$ composer require thecodingmachine/graphqlite-bundle +``` + +Now, go to the `config/packages/graphqlite.yaml` file and edit the namespaces to match your application. + +```yaml title="config/packages/graphqlite.yaml" +graphqlite: + namespace: + # The namespace(s) that will store your GraphQLite controllers. + # It accept either a string or a list of strings. + controllers: App\GraphQLController\ + # The namespace(s) that will store your GraphQL types and factories. + # It accept either a string or a list of strings. + types: + - App\Types\ + - App\Entity\ +``` + +More advanced parameters are detailed in the ["advanced configuration" section](#advanced-configuration) + +## Applications that don't use Symfony Flex + +Open a terminal in your current project directory and run: + +```console +$ composer require thecodingmachine/graphqlite-bundle +``` + +Enable the library by adding it to the list of registered bundles in the `app/AppKernel.php` file: + + +```php title="app/AppKernel.php" + + Do not put your GraphQL controllers in the App\Controller namespace Symfony applies a particular compiler pass to classes in the App\Controller namespace. This compiler pass will prevent you from using input types. Put your controllers in another namespace. We advise using App\GraphqlController. + + +The Symfony bundle come with a set of advanced features that are not described in this install documentation (like providing a login/logout mutation out of the box). Jump to the ["Symfony specific features"](symfony-bundle-advanced.mdx) documentation of GraphQLite if you want to learn more. diff --git a/website/versioned_docs/version-8.0.0/troubleshooting.md b/website/versioned_docs/version-8.0.0/troubleshooting.md new file mode 100644 index 0000000000..862cf9b4e7 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/troubleshooting.md @@ -0,0 +1,25 @@ +--- +id: troubleshooting +title: Troubleshooting +sidebar_label: Troubleshooting +--- + +**Error: Maximum function nesting level of '100' reached** + +Webonyx's GraphQL library tends to use a very deep stack. +This error does not necessarily mean your code is going into an infinite loop. +Simply try to increase the maximum allowed nesting level in your XDebug conf: + +``` +xdebug.max_nesting_level=500 +``` + + +**Cannot autowire service "_[some input type]_": argument "$..." of method "..." is type-hinted "...", you should configure its value explicitly.** + +The message says that Symfony is trying to instantiate an input type as a service. This can happen if you put your +GraphQLite controllers in the Symfony controller namespace (`App\Controller` by default). Symfony will assume that any +object type-hinted in a method of a controller is a service ([because all controllers are tagged with the "controller.service_arguments" tag](https://symfony.com/doc/current/service_container/3.3-di-changes.html#controllers-are-registered-as-services)) + +To fix this issue, do not put your GraphQLite controller in the same namespace as the Symfony controllers and +reconfigure your `config/graphqlite.yml` file to point to your new namespace. diff --git a/website/versioned_docs/version-8.0.0/type-mapping.mdx b/website/versioned_docs/version-8.0.0/type-mapping.mdx new file mode 100644 index 0000000000..d5b6c4dc29 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/type-mapping.mdx @@ -0,0 +1,383 @@ +--- +id: type-mapping +title: Type mapping +sidebar_label: Type mapping +--- + +As explained in the [queries](queries.mdx) section, the job of GraphQLite is to create GraphQL types from PHP types. + +## Scalar mapping + +Scalar PHP types can be type-hinted to the corresponding GraphQL types: + +* `string` +* `int` +* `bool` +* `float` + +For instance: + +```php +namespace App\Controller; + +use TheCodingMachine\GraphQLite\Annotations\Query; + +class MyController +{ + #[Query] + public function hello(string $name): string + { + return 'Hello ' . $name; + } +} +``` + +## Class mapping + +When returning a PHP class in a query, you must annotate this class using `#[Type]` and `#[Field]` attributes: + +```php +namespace App\Entities; + +use TheCodingMachine\GraphQLite\Annotations\Field; +use TheCodingMachine\GraphQLite\Annotations\Type; + +#[Type] +class Product +{ + // ... + + #[Field] + public function getName(): string + { + return $this->name; + } + + #[Field] + public function getPrice(): ?float + { + return $this->price; + } +} +``` + +**Note:** The GraphQL output type name generated by GraphQLite is equal to the class name of the PHP class. So if your +PHP class is `App\Entities\Product`, then the GraphQL type will be named "Product". + +In case you have several types with the same class name in different namespaces, you will face a naming collision. +Hopefully, you can force the name of the GraphQL output type using the "name" attribute: + +```php +#[Type(name: "MyProduct")] +class Product { /* ... */ } +``` + + + +## Array mapping + +You can type-hint against arrays (or iterators) as long as you add a detailed `@return` statement in the PHPDoc. + +```php +/** + * @return User[] <=== we specify that the array is an array of User objects. + */ +#[Query] +public function users(int $limit, int $offset): array +{ + // Some code that returns an array of "users". +} +``` + +## ID mapping + +GraphQL comes with a native `ID` type. PHP has no such type. + +There are two ways with GraphQLite to handle such type. + +### Force the outputType + +```php +#[Field(outputType: "ID")] +public function getId(): string +{ + // ... +} +``` + +Using the `outputType` attribute of the `#[Field]` attribute, you can force the output type to `ID`. + +You can learn more about forcing output types in the [custom types section](custom-types.mdx). + +### ID class + +```php +use TheCodingMachine\GraphQLite\Types\ID; + +#[Field] +public function getId(): ID +{ + // ... +} +``` + +Note that you can also use the `ID` class as an input type: + +```php +use TheCodingMachine\GraphQLite\Types\ID; + +#[Mutation] +public function save(ID $id, string $name): Product +{ + // ... +} +``` + +## Date mapping + +Out of the box, GraphQL does not have a `DateTime` type, but we took the liberty to add one, with sensible defaults. + +When used as an output type, `DateTimeImmutable` or `DateTimeInterface` PHP classes are +automatically mapped to this `DateTime` GraphQL type. + +```php +#[Field] +public function getDate(): \DateTimeInterface +{ + return $this->date; +} +``` + +The `date` field will be of type `DateTime`. In the returned JSON response to a query, the date is formatted as a string +in the **ISO8601** format (aka ATOM format). + +
+ PHP DateTime type is not supported. +
+ +## Union types + +Union types for return are supported in GraphQLite as of version 6.0: + +```php +#[Query] +public function companyOrContact(int $id): Company|Contact +{ + // Some code that returns a company or a contact. +} +``` + +## Enum types + +PHP 8.1 introduced native support for Enums. GraphQLite now also supports native enums as of version 5.1. + +```php +#[Type] +enum Status: string +{ + case ON = 'on'; + case OFF = 'off'; + case PENDING = 'pending'; +} +``` + +```php +/** + * @return User[] + */ +#[Query] +public function users(Status $status): array +{ + if ($status === Status::ON) { + // Your logic + } + // ... +} +``` + +```graphql +query users($status: Status!) {} + users(status: $status) { + id + } +} +``` + +By default, the name of the GraphQL enum type will be the name of the class. If you have a naming conflict (two classes +that live in different namespaces with the same class name), you can solve it using the `name` property on the `#[Type]` attribute: + +```php +namespace Model\User; + +#[Type(name: "UserStatus")] +enum Status: string +{ + // ... +} +``` + +### Enum types with myclabs/php-enum + +
+ This implementation is now deprecated and will be removed in the future. You are advised to use native enums instead. +
+ +*Prior to version 5.1, GraphQLite only supported Enums through the 3rd party library, [myclabs/php-enum](https://github.com/myclabs/php-enum). If you'd like to use this implementation you'll first need to add this library as a dependency to your application.* + +```bash +$ composer require myclabs/php-enum +``` + +Now, any class extending the `MyCLabs\Enum\Enum` class will be mapped to a GraphQL enum: + +```php +use MyCLabs\Enum\Enum; + +class StatusEnum extends Enum +{ + private const ON = 'on'; + private const OFF = 'off'; + private const PENDING = 'pending'; +} +``` + +```php +/** + * @return User[] + */ +#[Query] +public function users(StatusEnum $status): array +{ + if ($status == StatusEnum::ON()) { + // Note that the "magic" ON() method returns an instance of the StatusEnum class. + // Also, note that we are comparing this instance using "==" (using "===" would fail as we have 2 different instances here) + // ... + } + // ... +} +``` + +```graphql +query users($status: StatusEnum!) {} + users(status: $status) { + id + } +} +``` + +By default, the name of the GraphQL enum type will be the name of the class. If you have a naming conflict (two classes +that live in different namespaces with the same class name), you can solve it using the `#[EnumType]` attribute: + +```php +use TheCodingMachine\GraphQLite\Annotations\EnumType; + +#[EnumType(name: "UserStatus")] +class StatusEnum extends Enum +{ + // ... +} +``` + +
GraphQLite must be able to find all the classes extending the "MyCLabs\Enum" class +in your project. By default, GraphQLite will look for "Enum" classes in the namespaces declared for the types. For this +reason, your enum classes MUST be in one of the namespaces declared for the types in your GraphQLite +configuration file.
+ +## Deprecation of fields + +You can mark a field as deprecated in your GraphQL Schema by just annotating it with the `@deprecated` PHPDoc annotation. Note that a description (reason) is required for the annotation to be rendered. + +```php +namespace App\Entities; + +use TheCodingMachine\GraphQLite\Annotations\Field; +use TheCodingMachine\GraphQLite\Annotations\Type; + +#[Type] +class Product +{ + // ... + + #[Field] + public function getName(): string + { + return $this->name; + } + + /** + * @deprecated use field `name` instead + */ + #[Field] + public function getProductName(): string + { + return $this->name; + } +} +``` + +This will add the `@deprecated` directive to the field in the GraphQL Schema which sets the `isDeprecated` field to `true` and adds the reason to the `deprecationReason` field in an introspection query. Fields marked as deprecated can still be queried, but will be returned in an introspection query only if `includeDeprecated` is set to `true`. + +```graphql +query { + __type(name: "Product") { + fields(includeDeprecated: true) { + name + isDeprecated + deprecationReason + } + } +} +``` + +## Promise mapping + +GraphQL includes a native \GraphQL\Deferred type. +You can map the return type by adding a detailed `@return` statement in the PHPDoc. +An alternative to the `@return` statement is using `#[Field(outputType: SomeGQLType)]`. + +All the previously mentioned mappings work with Promises, except when a return type is explicitly declared +in the method signature. + +This allows you to use \Overblog\DataLoader\DataLoader as an alternative +for resolving N+1 query issues and caching intermediate results. + +```php +#[Type] +class Product +{ + // ... + + /** + * @return string + */ + #[Field] + public function getName(): Deferred + { + return new Deferred(fn() => $this->name); + } + + #[Field(outputType: "Float")] + public function getPrice(): Deferred + { + return new Deferred(fn() => $this->price); + } + + #[Field(outputType: "[String!]!")] + public function getCategories(#[Autowire('categoryDataLoader')] DataLoader $categoryDataLoader): SyncPromise + { + return $categoryDataLoader->load($this->id)->adoptedPromise; + } +} +``` + +## More scalar types + +Available in GraphQLite 4.0+ + +GraphQL supports "custom" scalar types. GraphQLite supports adding more GraphQL scalar types. + +If you need more types, you can check the [GraphQLite Misc. Types library](https://github.com/thecodingmachine/graphqlite-misc-types). +It adds support for more scalar types out of the box in GraphQLite. + +Or if you have some special needs, [you can develop your own scalar types](custom-types#registering-a-custom-scalar-type-advanced). diff --git a/website/versioned_docs/version-8.0.0/universal-service-providers.md b/website/versioned_docs/version-8.0.0/universal-service-providers.md new file mode 100644 index 0000000000..8375b0e1e4 --- /dev/null +++ b/website/versioned_docs/version-8.0.0/universal-service-providers.md @@ -0,0 +1,74 @@ +--- +id: universal-service-providers +title: "Getting started with a framework compatible with container-interop/service-provider" +sidebar_label: Universal service providers +--- + +[container-interop/service-provider](https://github.com/container-interop/service-provider/) is an experimental project +aiming to bring interoperability between framework module systems. + +If your framework is compatible with [container-interop/service-provider](https://github.com/container-interop/service-provider/), +GraphQLite comes with a service provider that you can leverage. + +## Installation + +Open a terminal in your current project directory and run: + +```console +$ composer require thecodingmachine/graphqlite-universal-service-provider +``` + +## Requirements + +In order to bootstrap GraphQLite, you will need: + +- A PSR-16 cache + +Additionally, you will have to route the HTTP requests to the underlying GraphQL library. + +GraphQLite relies on the [webonyx/graphql-php](http://webonyx.github.io/graphql-php/) library internally. +This library plays well with PSR-7 requests and we provide a [PSR-15 middleware](other-frameworks.mdx). + +## Integration + +Webonyx/graphql-php library requires a [Schema](https://webonyx.github.io/graphql-php/type-system/schema/) in order to resolve +GraphQL queries. The service provider provides this `Schema` class. + +[Checkout the the service-provider documentation](https://github.com/thecodingmachine/graphqlite-universal-service-provider) + +## Sample usage + +```json title="composer.json" +{ + "require": { + "mnapoli/simplex": "^0.5", + "thecodingmachine/graphqlite-universal-service-provider": "^3", + "thecodingmachine/symfony-cache-universal-module": "^1" + }, + "minimum-stability": "dev", + "prefer-stable": true +} +``` + +```php title="index.php" +set('graphqlite.namespace.types', ['App\\Types']); +$container->set('graphqlite.namespace.controllers', ['App\\Controllers']); + +$schema = $container->get(Schema::class); + +// or if you want the PSR-15 middleware: + +$middleware = $container->get(Psr15GraphQLMiddlewareBuilder::class); +``` diff --git a/website/versioned_docs/version-8.0.0/validation.mdx b/website/versioned_docs/version-8.0.0/validation.mdx new file mode 100644 index 0000000000..dde8a9413a --- /dev/null +++ b/website/versioned_docs/version-8.0.0/validation.mdx @@ -0,0 +1,188 @@ +--- +id: validation +title: Validation +sidebar_label: User input validation +--- + +GraphQLite does not handle user input validation by itself. It is out of its scope. + +However, it can integrate with your favorite framework validation mechanism. The way you validate user input will +therefore depend on the framework you are using. + +## Validating user input with Laravel + +If you are using Laravel, jump directly to the [GraphQLite Laravel package advanced documentation](laravel-package-advanced.mdx#support-for-laravel-validation-rules) +to learn how to use the Laravel validation with GraphQLite. + +## Validating user input with Symfony validator + +GraphQLite provides a bridge to use the [Symfony validator](https://symfony.com/doc/current/validation.html) directly in your application. + +- If you are using Symfony and the Symfony GraphQLite bundle, the bridge is available out of the box +- If you are using another framework, the "Symfony validator" component can be used in standalone mode. If you want to + add it to your project, you can require the *thecodingmachine/graphqlite-symfony-validator-bridge* package: + + ```bash + $ composer require thecodingmachine/graphqlite-symfony-validator-bridge + ``` + +### Using the Symfony validator bridge + +Usually, when you use the Symfony validator component, you put attributes in your entities and you validate those entities +using the `Validator` object. + +```php title="UserController.php" +use Symfony\Component\Validator\Validator\ValidatorInterface; +use TheCodingMachine\GraphQLite\Validator\ValidationFailedException + +class UserController +{ + private $validator; + + public function __construct(ValidatorInterface $validator) + { + $this->validator = $validator; + } + + #[Mutation] + public function createUser(string $email, string $password): User + { + $user = new User($email, $password); + + // Let's validate the user + $errors = $this->validator->validate($user); + + // Throw an appropriate GraphQL exception if validation errors are encountered + ValidationFailedException::throwException($errors); + + // No errors? Let's continue and save the user + // ... + } +} +``` + +Validation rules are added directly to the object in the domain model: + +```php title="User.php" +use Symfony\Component\Validator\Constraints as Assert; + +class User +{ + #[Assert\Email(message: "The email '{{ value }}' is not a valid email.", checkMX: true)] + private $email; + + /** + * The NotCompromisedPassword assertion asks the "HaveIBeenPawned" service if your password has already leaked or not. + */ + #[Assert\NotCompromisedPassword] + private $password; + + public function __construct(string $email, string $password) + { + $this->email = $email; + $this->password = $password; + } + + // ... +} +``` + +If a validation fails, GraphQLite will return the failed validations in the "errors" section of the JSON response: + +```json +{ + "errors": [ + { + "message": "The email '\"foo@thisdomaindoesnotexistatall.com\"' is not a valid email.", + "extensions": { + "code": "bf447c1c-0266-4e10-9c6c-573df282e413", + "field": "email" + } + } + ] +} +``` + + +### Using the validator directly on a query / mutation / subscription / factory ... + +If the data entered by the user is mapped to an object, please use the "validator" instance directly as explained in +the last chapter. It is a best practice to put your validation layer as close as possible to your domain model. + +If the data entered by the user is **not** mapped to an object, you can directly annotate your query, mutation, factory... + +
+ You generally don't want to do this. It is a best practice to put your validation constraints +on your domain objects. Only use this technique if you want to validate user input and user input will not be stored +in a domain object. +
+ +Use the `#[Assertion]` attribute to validate directly the user input. + +```php +use Symfony\Component\Validator\Constraints as Assert; +use TheCodingMachine\GraphQLite\Validator\Annotations\Assertion; +use TheCodingMachine\GraphQLite\Annotations\Query; + +#[Query] +#[Assertion(for: "email", constraint: new Assert\Email())] +public function findByMail(string $email): User +{ + // ... +} +``` + +Notice that the "constraint" parameter contains an attribute (it is an attribute wrapped in an attribute). + +You can also pass an array to the `constraint` parameter: + +```php +#[Assertion(for: "email", constraint: [new Assert\NotBlack(), new Assert\Email()])] +``` + +## Custom InputType Validation + +GraphQLite also supports a fully custom validation implementation for all input types defined with an `#[Input]` attribute. This offers a way to validate input types before they're available as a method parameter of your query and mutation controllers. This way, when you're using your query or mutation controllers, you can feel confident that your input type objects have already been validated. + +
+

It's important to note that this validation implementation does not validate input types created with a factory. If you are creating an input type with a factory, or using primitive parameters in your query/mutation controllers, you should be sure to validate these independently. This is strictly for input type objects.

+ +

You can use one of the framework validation libraries listed above or implement your own validation for these cases. If you're using input type objects for most all of your query and mutation controllers, then there is little additional validation concerns with regards to user input. There are many reasons why you should consider defaulting to an InputType object, as opposed to individual arguments, for your queries and mutations. This is just one additional perk.

+
+ +To get started with validation on input types defined by an `#[Input]` attribute, you'll first need to register your validator with the `SchemaFactory`. + +```php +$factory = new SchemaFactory($cache, $this->container); +$factory->addNamespace('App'); +// Register your validator +$factory->setInputTypeValidator($this->container->get('your_validator')); +$factory->createSchema(); +``` + +Your input type validator must implement the `TheCodingMachine\GraphQLite\Types\InputTypeValidatorInterface`, as shown below: + +```php +interface InputTypeValidatorInterface +{ + /** + * Checks to see if the Validator is currently enabled. + */ + public function isEnabled(): bool; + + /** + * Performs the validation of the InputType. + * + * @param object $input The input type object to validate + */ + public function validate(object $input): void; +} +``` + +The interface is quite simple. Handle all of your own validation logic in the `validate` method. For example, you might use Symfony's attribute based validation in addition to some other custom validation logic. It's really up to you on how you wish to handle your own validation. The `validate` method will receive the input type object populated with the user input. + +You'll notice that the `validate` method has a `void` return. The purpose here is to encourage you to throw an Exception or handle validation output however you best see fit. GraphQLite does it's best to stay out of your way and doesn't make attempts to handle validation output. You can, however, throw an instance of `TheCodingMachine\GraphQLite\Exceptions\GraphQLException` or `TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException` as usual (see [Error Handling](error-handling) for more details). + +Also available is the `isEnabled` method. This method is checked before executing validation on an InputType being resolved. You can work out your own logic to selectively enable or disable validation through this method. In most cases, you can simply return `true` to keep it always enabled. + +And that's it, now, anytime an input type is resolved, the validator will be executed on that input type immediately after it has been hydrated with user input. diff --git a/website/versioned_sidebars/version-8.0.0-sidebars.json b/website/versioned_sidebars/version-8.0.0-sidebars.json new file mode 100644 index 0000000000..571f243dba --- /dev/null +++ b/website/versioned_sidebars/version-8.0.0-sidebars.json @@ -0,0 +1,58 @@ +{ + "docs": { + "Introduction": [ + "index" + ], + "Installation": [ + "getting-started", + "symfony-bundle", + "laravel-package", + "universal-service-providers", + "other-frameworks" + ], + "Usage": [ + "queries", + "mutations", + "subscriptions", + "type-mapping", + "autowiring", + "extend-type", + "external-type-declaration", + "input-types", + "inheritance-interfaces", + "error-handling", + "validation" + ], + "Security": [ + "authentication-authorization", + "fine-grained-security", + "implementing-security", + "operation-complexity" + ], + "Performance": [ + "query-plan", + "prefetch-method", + "automatic-persisted-queries" + ], + "Advanced": [ + "file-uploads", + "pagination", + "custom-types", + "field-middlewares", + "argument-resolving", + "extend-input-type", + "multiple-output-types", + "symfony-bundle-advanced", + "laravel-package-advanced", + "internals", + "troubleshooting", + "migrating" + ], + "Reference": [ + "doctrine-annotations-attributes", + "annotations-reference", + "semver", + "changelog" + ] + } +} diff --git a/website/versions.json b/website/versions.json index c6985e8cd8..5e6b133e0f 100644 --- a/website/versions.json +++ b/website/versions.json @@ -1,4 +1,5 @@ [ + "8.0.0", "7.0.0", "6.1", "6.0", From 7a75a73eb29ee3837cb935c3f05f68cfb37f1fba Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Wed, 18 Dec 2024 02:26:29 -0500 Subject: [PATCH 093/108] Fixed Adapter class for compatibility with base ObjectType (#721) --- src/Mappers/Proxys/MutableAdapterTrait.php | 4 ++-- src/Mappers/Proxys/MutableObjectTypeAdapter.php | 13 +------------ 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/src/Mappers/Proxys/MutableAdapterTrait.php b/src/Mappers/Proxys/MutableAdapterTrait.php index f4f2f8b3ad..5c780b2471 100644 --- a/src/Mappers/Proxys/MutableAdapterTrait.php +++ b/src/Mappers/Proxys/MutableAdapterTrait.php @@ -39,13 +39,13 @@ public function assertValid(): void $this->type->assertValid(); } - public function jsonSerialize():string + public function jsonSerialize(): string { return $this->type->jsonSerialize(); } - public function toString():string + public function toString(): string { return $this->type->toString(); } diff --git a/src/Mappers/Proxys/MutableObjectTypeAdapter.php b/src/Mappers/Proxys/MutableObjectTypeAdapter.php index cae384e1e0..b4256631a8 100644 --- a/src/Mappers/Proxys/MutableObjectTypeAdapter.php +++ b/src/Mappers/Proxys/MutableObjectTypeAdapter.php @@ -3,23 +3,11 @@ namespace TheCodingMachine\GraphQLite\Mappers\Proxys; -use Exception; -use GraphQL\Error\InvariantViolation; -use GraphQL\Type\Definition\FieldDefinition; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\ResolveInfo; -use GraphQL\Utils\Utils; -use RuntimeException; -use TheCodingMachine\GraphQLite\Types\MutableInterface; use TheCodingMachine\GraphQLite\Types\MutableObjectType; -use TheCodingMachine\GraphQLite\Types\NoFieldsException; use function assert; -use function call_user_func; -use function is_array; -use function is_callable; -use function is_string; -use function sprintf; /** * An adapter class (actually a proxy) that adds the "mutable" feature to any Webonyx ObjectType. @@ -36,6 +24,7 @@ public function __construct(ObjectType $type, ?string $className = null) $this->type = $type; $this->className = $className; $this->name = $type->name; + $this->description = $type->description; $this->config = $type->config; $this->astNode = $type->astNode; $this->extensionASTNodes = $type->extensionASTNodes; From 7b7ae1623a54395835a5e0e473bd324acb2e8d24 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Dec 2024 04:44:52 -0500 Subject: [PATCH 094/108] Bump actions/upload-artifact from 3 to 4 (#645) * Bump actions/upload-artifact from 3 to 4 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3 to 4. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * Update continuous_integration.yml * Update continuous_integration.yml --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jacob Thomason --- .github/workflows/continuous_integration.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 15e2b3ca31..f975c8b9af 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -78,9 +78,9 @@ jobs: run: "composer cs-check" - name: "Archive code coverage results" - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: "codeCoverage" + name: codeCoverage-${{ matrix.php-version }} path: "build" overwrite: true From fbb20d267a7fd832f0984a64c012a6a080e17a11 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Wed, 18 Dec 2024 07:50:08 -0500 Subject: [PATCH 095/108] PHP 8.4 Support (#722) * PHP 8.4 Support Add PHP 8.4 to list of CI runners and confirm PHP 8.4 support * Updated dependencies and resolved PHPStan errors * Downgrade symfony/var-dumper for PHP 8.1 support * Support phpunit 10.5 for PHP 8.1 * Corrected call for enum interface inconsistency * Resolved PHPCS errors --- .github/workflows/continuous_integration.yml | 2 +- composer.json | 17 +++++----- phpstan.neon | 7 ++-- src/Annotations/Security.php | 6 ---- src/Exceptions/GraphQLAggregateException.php | 8 ++++- src/FactoryContext.php | 2 +- src/FieldsBuilder.php | 12 +++---- src/Http/Psr15GraphQLMiddlewareBuilder.php | 1 + src/Http/WebonyxGraphqlMiddleware.php | 22 ++++--------- src/InputTypeUtils.php | 1 - src/Mappers/Parameters/TypeHandler.php | 23 ++++--------- .../Proxys/MutableInterfaceTypeAdapter.php | 17 ++-------- .../Proxys/MutableObjectTypeAdapter.php | 2 +- src/Mappers/RecursiveTypeMapper.php | 3 +- src/Mappers/Root/EnumTypeMapper.php | 10 +++--- src/Mappers/Root/IteratorTypeMapper.php | 2 +- .../StaticClassListTypeMapperFactory.php | 2 +- src/Middlewares/SecurityFieldMiddleware.php | 32 ++++++------------- .../SecurityInputFieldMiddleware.php | 3 -- src/Schema.php | 6 +--- src/Types/EnumType.php | 2 +- src/Types/ID.php | 3 +- src/Types/TypeAnnotatedObjectType.php | 1 - tests/Integration/EndToEndTest.php | 1 - 24 files changed, 64 insertions(+), 121 deletions(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index f975c8b9af..e7d5c579ba 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: install-args: ['', '--prefer-lowest'] - php-version: ['8.1', '8.2', '8.3'] + php-version: ['8.1', '8.2', '8.3', '8.4'] fail-fast: false steps: diff --git a/composer.json b/composer.json index d4ef009482..33751a0af7 100644 --- a/composer.json +++ b/composer.json @@ -27,17 +27,16 @@ "kcs/class-finder": "^0.6.0" }, "require-dev": { - "beberlei/porpaginas": "^1.2 || ^2.0", - "doctrine/coding-standard": "^11.0 || ^12.0", + "beberlei/porpaginas": "^2.0", + "doctrine/coding-standard": "^12.0", "ecodev/graphql-upload": "^7.0", - "laminas/laminas-diactoros": "^2 || ^3", + "laminas/laminas-diactoros": "^3.5", "myclabs/php-enum": "^1.6.6", - "php-coveralls/php-coveralls": "^2.1", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.11", - "phpunit/phpunit": "^10.1 || ^11.0", - "symfony/var-dumper": "^5.4 || ^6.0 || ^7", - "thecodingmachine/phpstan-strict-rules": "^1.0" + "php-coveralls/php-coveralls": "^2.7", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^10.5 || ^11.0", + "symfony/var-dumper": "^6.4" }, "suggest": { "beberlei/porpaginas": "If you want automatic pagination in your GraphQL types", diff --git a/phpstan.neon b/phpstan.neon index 86c9770f50..11a355ed6b 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,12 +4,7 @@ parameters: tmpDir: .phpstan-cache paths: - src - excludePaths: - # TODO: exlude only for PHP < 8.1 - - src/Mappers/Root/EnumTypeMapper.php - - src/Types/EnumType.php level: 8 - checkGenericClassInNonGenericObjectType: false reportUnmatchedIgnoredErrors: false treatPhpDocTypesAsCertain: false ignoreErrors: @@ -42,3 +37,5 @@ parameters: - message: '#Call to an undefined method object::__toString\(\)#' path : src/Types/ID.php + - + identifier: missingType.generics diff --git a/src/Annotations/Security.php b/src/Annotations/Security.php index c41c396f60..6e334e3e45 100644 --- a/src/Annotations/Security.php +++ b/src/Annotations/Security.php @@ -6,13 +6,9 @@ use Attribute; use BadMethodCallException; -use TypeError; use function array_key_exists; -use function gettype; -use function is_array; use function is_string; -use function sprintf; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class Security implements MiddlewareAnnotationInterface @@ -37,8 +33,6 @@ public function __construct(array|string $data = [], string|null $expression = n { if (is_string($data)) { $data = ['expression' => $data]; - } elseif (! is_array($data)) { - throw new TypeError(sprintf('"%s": Argument $data is expected to be a string or array, got "%s".', __METHOD__, gettype($data))); } $this->expression = $data['value'] ?? $data['expression'] ?? $expression; diff --git a/src/Exceptions/GraphQLAggregateException.php b/src/Exceptions/GraphQLAggregateException.php index d63ca3a0c3..5c7c1909b8 100644 --- a/src/Exceptions/GraphQLAggregateException.php +++ b/src/Exceptions/GraphQLAggregateException.php @@ -6,6 +6,7 @@ use Exception; use GraphQL\Error\ClientAware; +use RuntimeException; use Throwable; use function array_map; @@ -22,7 +23,7 @@ class GraphQLAggregateException extends Exception implements GraphQLAggregateExc /** @param (ClientAware&Throwable)[] $exceptions */ public function __construct(iterable $exceptions = []) { - parent::__construct('Many exceptions have be thrown:'); + parent::__construct('Many exceptions have been thrown:'); foreach ($exceptions as $exception) { $this->add($exception); @@ -56,6 +57,11 @@ private function updateCode(): void $codes = array_map(static function (Throwable $t) { return $t->getCode(); }, $this->exceptions); + + if (count($codes) === 0) { + throw new RuntimeException('Unable to determine code for exception'); + } + $this->code = max($codes); } diff --git a/src/FactoryContext.php b/src/FactoryContext.php index 0aaf40ac42..672d90ca99 100644 --- a/src/FactoryContext.php +++ b/src/FactoryContext.php @@ -103,7 +103,7 @@ public function getClassFinderComputedCache(): ClassFinderComputedCache return $this->classFinderComputedCache; } - public function getClassBoundCache(): ClassBoundCache|null + public function getClassBoundCache(): ClassBoundCache { return $this->classBoundCache; } diff --git a/src/FieldsBuilder.php b/src/FieldsBuilder.php index d8ab99cc6b..10b0c620ae 100644 --- a/src/FieldsBuilder.php +++ b/src/FieldsBuilder.php @@ -497,6 +497,7 @@ private function getFieldsByMethodAnnotations( $resolver = is_string($controller) ? new SourceMethodResolver($refMethod) + /** @phpstan-ignore argument.type */ : new ServiceResolver([$controller, $methodName]); $fieldDescriptor = new QueryFieldDescriptor( @@ -512,7 +513,7 @@ private function getFieldsByMethodAnnotations( ); $field = $this->fieldMiddleware->process($fieldDescriptor, new class implements FieldHandlerInterface { - public function handle(QueryFieldDescriptor $fieldDescriptor): FieldDefinition|null + public function handle(QueryFieldDescriptor $fieldDescriptor): FieldDefinition { return QueryField::fromFieldDescriptor($fieldDescriptor); } @@ -605,7 +606,7 @@ private function getFieldsByPropertyAnnotations( ); $field = $this->fieldMiddleware->process($fieldDescriptor, new class implements FieldHandlerInterface { - public function handle(QueryFieldDescriptor $fieldDescriptor): FieldDefinition|null + public function handle(QueryFieldDescriptor $fieldDescriptor): FieldDefinition { return QueryField::fromFieldDescriptor($fieldDescriptor); } @@ -744,7 +745,7 @@ private function getQueryFieldsFromSourceFields( ->withMiddlewareAnnotations($sourceField->getMiddlewareAnnotations()); $field = $this->fieldMiddleware->process($fieldDescriptor, new class implements FieldHandlerInterface { - public function handle(QueryFieldDescriptor $fieldDescriptor): FieldDefinition|null + public function handle(QueryFieldDescriptor $fieldDescriptor): FieldDefinition { return QueryField::fromFieldDescriptor($fieldDescriptor); } @@ -822,7 +823,6 @@ private function resolvePhpType( $context = $this->docBlockFactory->createContext($refClass); $phpdocType = $typeResolver->resolve($phpTypeStr, $context); - assert($phpdocType !== null); $fakeDocBlock = new DocBlock('', null, [new DocBlock\Tags\Return_($phpdocType)], $context); return $this->typeMapper->mapReturnType($refMethod, $fakeDocBlock); @@ -1080,7 +1080,7 @@ private function getInputFieldsByMethodAnnotations( ); $field = $this->inputFieldMiddleware->process($inputFieldDescriptor, new class implements InputFieldHandlerInterface { - public function handle(InputFieldDescriptor $inputFieldDescriptor): InputField|null + public function handle(InputFieldDescriptor $inputFieldDescriptor): InputField { return InputField::fromFieldDescriptor($inputFieldDescriptor); } @@ -1175,7 +1175,7 @@ private function getInputFieldsByPropertyAnnotations( ); $field = $this->inputFieldMiddleware->process($inputFieldDescriptor, new class implements InputFieldHandlerInterface { - public function handle(InputFieldDescriptor $inputFieldDescriptor): InputField|null + public function handle(InputFieldDescriptor $inputFieldDescriptor): InputField { return InputField::fromFieldDescriptor($inputFieldDescriptor); } diff --git a/src/Http/Psr15GraphQLMiddlewareBuilder.php b/src/Http/Psr15GraphQLMiddlewareBuilder.php index 76bb4d6de8..c36ee03633 100644 --- a/src/Http/Psr15GraphQLMiddlewareBuilder.php +++ b/src/Http/Psr15GraphQLMiddlewareBuilder.php @@ -51,6 +51,7 @@ public function __construct(Schema $schema) $this->config->setSchema($schema); $this->config->setDebugFlag(DebugFlag::RETHROW_UNSAFE_EXCEPTIONS); $this->config->setErrorFormatter([WebonyxErrorHandler::class, 'errorFormatter']); + /** @phpstan-ignore argument.type */ $this->config->setErrorsHandler([WebonyxErrorHandler::class, 'errorHandler']); $this->config->setContext(new Context()); $this->config->setPersistedQueryLoader(new NotSupportedPersistedQueryLoader()); diff --git a/src/Http/WebonyxGraphqlMiddleware.php b/src/Http/WebonyxGraphqlMiddleware.php index ac49cc2939..0bd7fa095e 100644 --- a/src/Http/WebonyxGraphqlMiddleware.php +++ b/src/Http/WebonyxGraphqlMiddleware.php @@ -19,6 +19,7 @@ use TheCodingMachine\GraphQLite\Context\ResetableContextInterface; use function array_map; +use function count; use function explode; use function in_array; use function is_array; @@ -99,11 +100,7 @@ private function processResult(ExecutionResult|array|Promise $result): array }, $result); } - if ($result instanceof Promise) { - throw new RuntimeException('Only SyncPromiseAdapter is supported'); - } - - throw new RuntimeException('Unexpected response from StandardServer::executePsrRequest'); // @codeCoverageIgnore + throw new RuntimeException('Only SyncPromiseAdapter is supported'); } /** @param ExecutionResult|array|Promise $result */ @@ -118,19 +115,14 @@ private function decideHttpCode(ExecutionResult|array|Promise $result): int return $this->httpCodeDecider->decideHttpStatusCode($executionResult); }, $result); - return (int) max($codes); - } + if (count($codes) === 0) { + throw new RuntimeException('Unable to determine HTTP status code'); + } - // @codeCoverageIgnoreStart - // Code unreachable because exceptions will be triggered in processResult first. - // We keep it for defensive programming purpose - if ($result instanceof Promise) { - throw new RuntimeException('Only SyncPromiseAdapter is supported'); + return (int) max($codes); } - throw new RuntimeException('Unexpected response from StandardServer::executePsrRequest'); - - // @codeCoverageIgnoreEnd + throw new RuntimeException('Only SyncPromiseAdapter is supported'); } private function isGraphqlRequest(ServerRequestInterface $request): bool diff --git a/src/InputTypeUtils.php b/src/InputTypeUtils.php index 3e4ad37fbb..319fc7eebf 100644 --- a/src/InputTypeUtils.php +++ b/src/InputTypeUtils.php @@ -73,7 +73,6 @@ private function validateReturnType(ReflectionMethod $refMethod): Fqsen $typeResolver = new TypeResolver(); $phpdocType = $typeResolver->resolve($type); - assert($phpdocType !== null); $phpdocType = $this->resolveSelf($phpdocType, $refMethod->getDeclaringClass()); if (! $phpdocType instanceof Object_) { throw MissingTypeHintRuntimeException::invalidReturnType($refMethod); diff --git a/src/Mappers/Parameters/TypeHandler.php b/src/Mappers/Parameters/TypeHandler.php index b4b8d098ee..393368fa2e 100644 --- a/src/Mappers/Parameters/TypeHandler.php +++ b/src/Mappers/Parameters/TypeHandler.php @@ -53,7 +53,6 @@ use function explode; use function in_array; use function iterator_to_array; -use function method_exists; use function reset; use function trim; @@ -74,7 +73,10 @@ public function __construct( $this->phpDocumentorTypeResolver = new PhpDocumentorTypeResolver(); } - public function mapReturnType(ReflectionMethod $refMethod, DocBlock $docBlockObj): GraphQLType&OutputType + public function mapReturnType( + ReflectionMethod $refMethod, + DocBlock $docBlockObj, + ): GraphQLType&OutputType { $returnType = $refMethod->getReturnType(); if ($returnType !== null) { @@ -94,7 +96,7 @@ public function mapReturnType(ReflectionMethod $refMethod, DocBlock $docBlockObj $refMethod, $docBlockObj, ); - assert($type instanceof GraphQLType && $type instanceof OutputType); + assert(! $type instanceof InputType); } catch (CannotMapTypeExceptionInterface $e) { $e->addReturnInfo($refMethod); throw $e; @@ -318,21 +320,14 @@ public function mapInputProperty( } if ($isNullable === null) { - $isNullable = false; - // getType function on property reflection is available only since PHP 7.4 - if (method_exists($refProperty, 'getType')) { - $refType = $refProperty->getType(); - if ($refType !== null) { - $isNullable = $refType->allowsNull(); - } - } + $isNullable = $refProperty->getType()?->allowsNull() ?? false; } if ($inputTypeName) { $inputType = $this->typeResolver->mapNameToInputType($inputTypeName); } else { $inputType = $this->mapPropertyType($refProperty, $docBlock, true, $argumentName, $isNullable); - assert($inputType instanceof InputType && $inputType instanceof GraphQLType); + assert(! $inputType instanceof OutputType); } $hasDefault = $defaultValue !== null || $isNullable; @@ -452,8 +447,6 @@ private function reflectionTypeToPhpDocType(ReflectionType $type, ReflectionClas assert($type instanceof ReflectionNamedType || $type instanceof ReflectionUnionType); if ($type instanceof ReflectionNamedType) { $phpdocType = $this->phpDocumentorTypeResolver->resolve($type->getName()); - assert($phpdocType !== null); - $phpdocType = $this->resolveSelf($phpdocType, $reflectionClass); if ($type->allowsNull()) { @@ -467,8 +460,6 @@ private function reflectionTypeToPhpDocType(ReflectionType $type, ReflectionClas function ($namedType) use ($reflectionClass): Type { assert($namedType instanceof ReflectionNamedType); $phpdocType = $this->phpDocumentorTypeResolver->resolve($namedType->getName()); - assert($phpdocType !== null); - $phpdocType = $this->resolveSelf($phpdocType, $reflectionClass); if ($namedType->allowsNull()) { diff --git a/src/Mappers/Proxys/MutableInterfaceTypeAdapter.php b/src/Mappers/Proxys/MutableInterfaceTypeAdapter.php index 2f179ab4e1..ffa954aa91 100644 --- a/src/Mappers/Proxys/MutableInterfaceTypeAdapter.php +++ b/src/Mappers/Proxys/MutableInterfaceTypeAdapter.php @@ -3,22 +3,9 @@ namespace TheCodingMachine\GraphQLite\Mappers\Proxys; -use Exception; -use GraphQL\Error\InvariantViolation; -use GraphQL\Type\Definition\FieldDefinition; use GraphQL\Type\Definition\InterfaceType; -use GraphQL\Type\Definition\ObjectType; -use GraphQL\Type\Definition\ResolveInfo; -use GraphQL\Utils\Utils; -use RuntimeException; -use TheCodingMachine\GraphQLite\Types\MutableInterface; +use TheCodingMachine\GraphQLite\Mappers\Proxys\MutableAdapterTrait; use TheCodingMachine\GraphQLite\Types\MutableInterfaceType; -use TheCodingMachine\GraphQLite\Types\NoFieldsException; -use function call_user_func; -use function is_array; -use function is_callable; -use function is_string; -use function sprintf; /** * An adapter class (actually a proxy) that adds the "mutable" feature to any Webonyx ObjectType. @@ -27,7 +14,6 @@ */ final class MutableInterfaceTypeAdapter extends MutableInterfaceType { - /** @use MutableAdapterTrait */ use MutableAdapterTrait; public function __construct(InterfaceType $type, ?string $className = null) @@ -35,6 +21,7 @@ public function __construct(InterfaceType $type, ?string $className = null) $this->type = $type; $this->className = $className; $this->name = $type->name; + $this->description = $type->description; $this->config = $type->config; $this->astNode = $type->astNode; $this->extensionASTNodes = $type->extensionASTNodes; diff --git a/src/Mappers/Proxys/MutableObjectTypeAdapter.php b/src/Mappers/Proxys/MutableObjectTypeAdapter.php index b4256631a8..53ac3e4fc6 100644 --- a/src/Mappers/Proxys/MutableObjectTypeAdapter.php +++ b/src/Mappers/Proxys/MutableObjectTypeAdapter.php @@ -6,6 +6,7 @@ use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\ResolveInfo; +use TheCodingMachine\GraphQLite\Mappers\Proxys\MutableAdapterTrait; use TheCodingMachine\GraphQLite\Types\MutableObjectType; use function assert; @@ -16,7 +17,6 @@ */ final class MutableObjectTypeAdapter extends MutableObjectType { - /** @use MutableAdapterTrait */ use MutableAdapterTrait; public function __construct(ObjectType $type, ?string $className = null) diff --git a/src/Mappers/RecursiveTypeMapper.php b/src/Mappers/RecursiveTypeMapper.php index f55c341419..12f6d8af43 100644 --- a/src/Mappers/RecursiveTypeMapper.php +++ b/src/Mappers/RecursiveTypeMapper.php @@ -237,7 +237,6 @@ public function findInterfaces(string $className): array { $interfaces = []; - /** @var array> $implements */ $implements = class_implements($className); foreach ($implements as $interface) { if (! $this->typeMapper->canMapClassToType($interface)) { @@ -359,7 +358,7 @@ public function mapClassToInterfaceOrType(string $className, OutputType|null $su $supportedClasses = $this->getClassTree(); if ($objectType instanceof ObjectFromInterfaceType) { $this->interfaces[$cacheKey] = $objectType->getInterfaces()[0]; - } elseif ($objectType instanceof MutableObjectType && isset($supportedClasses[$closestClassName]) && ! empty($supportedClasses[$closestClassName]->getChildren())) { + } elseif (isset($supportedClasses[$closestClassName]) && ! empty($supportedClasses[$closestClassName]->getChildren())) { // Cast as an interface $this->interfaces[$cacheKey] = new InterfaceFromObjectType($this->namingStrategy->getInterfaceNameFromConcreteName($objectType->name), $objectType, $subType, $this); $this->typeRegistry->registerType($this->interfaces[$cacheKey]); diff --git a/src/Mappers/Root/EnumTypeMapper.php b/src/Mappers/Root/EnumTypeMapper.php index 64d4696b9c..87e575bb6c 100644 --- a/src/Mappers/Root/EnumTypeMapper.php +++ b/src/Mappers/Root/EnumTypeMapper.php @@ -17,6 +17,7 @@ use ReflectionMethod; use ReflectionProperty; use TheCodingMachine\GraphQLite\AnnotationReader; +use TheCodingMachine\GraphQLite\Annotations\Type as TypeAnnotation; use TheCodingMachine\GraphQLite\Discovery\Cache\ClassFinderComputedCache; use TheCodingMachine\GraphQLite\Discovery\ClassFinder; use TheCodingMachine\GraphQLite\Reflection\DocBlock\DocBlockFactory; @@ -119,10 +120,11 @@ private function mapByClassName(string $enumClass): EnumType|null $typeName = $typeAnnotation?->getName() ?? $reflectionEnum->getShortName(); // Expose values instead of names if specifically configured to and if enum is string-backed - $useValues = $typeAnnotation !== null && - $typeAnnotation->useEnumValues() && - $reflectionEnum->isBacked() && - (string) $reflectionEnum->getBackingType() === 'string'; + $useValues = $typeAnnotation !== null + && $typeAnnotation instanceof TypeAnnotation + && $typeAnnotation->useEnumValues() + && $reflectionEnum->isBacked() + && (string) $reflectionEnum->getBackingType() === 'string'; $enumDescription = $this->docBlockFactory ->create($reflectionEnum) diff --git a/src/Mappers/Root/IteratorTypeMapper.php b/src/Mappers/Root/IteratorTypeMapper.php index 8d9f9fb1da..767f0489f6 100644 --- a/src/Mappers/Root/IteratorTypeMapper.php +++ b/src/Mappers/Root/IteratorTypeMapper.php @@ -146,7 +146,7 @@ private function toGraphQLType(Compound $type, Closure $topToGraphQLType, bool $ // By convention, we trim the NonNull part of the "$subGraphQlType" if ($subGraphQlType instanceof NonNull) { $subGraphQlType = $subGraphQlType->getWrappedType(); - assert($subGraphQlType instanceof OutputType && $subGraphQlType instanceof GraphQLType); + assert($subGraphQlType instanceof OutputType); } } else { $subGraphQlType = null; diff --git a/src/Mappers/StaticClassListTypeMapperFactory.php b/src/Mappers/StaticClassListTypeMapperFactory.php index 09fca187ae..4e82017f20 100644 --- a/src/Mappers/StaticClassListTypeMapperFactory.php +++ b/src/Mappers/StaticClassListTypeMapperFactory.php @@ -16,7 +16,7 @@ final class StaticClassListTypeMapperFactory implements TypeMapperFactoryInterfa /** * StaticClassListTypeMapperFactory constructor. * - * @param array $classList The list of classes to be scanned. + * @param list $classList The list of classes to be scanned. */ public function __construct( private array $classList, diff --git a/src/Middlewares/SecurityFieldMiddleware.php b/src/Middlewares/SecurityFieldMiddleware.php index d9fabaa0c1..f9d744afa7 100644 --- a/src/Middlewares/SecurityFieldMiddleware.php +++ b/src/Middlewares/SecurityFieldMiddleware.php @@ -19,19 +19,23 @@ use function array_combine; use function array_keys; use function assert; -use function is_array; /** * A field middleware that reads "Security" Symfony annotations. */ class SecurityFieldMiddleware implements FieldMiddlewareInterface { - public function __construct(private readonly ExpressionLanguage $language, private readonly AuthenticationServiceInterface $authenticationService, private readonly AuthorizationServiceInterface $authorizationService/*, ?LoggerInterface $logger = null*/) - { - /*$this->logger = $logger;*/ + public function __construct( + private readonly ExpressionLanguage $language, + private readonly AuthenticationServiceInterface $authenticationService, + private readonly AuthorizationServiceInterface $authorizationService, + ) { } - public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandlerInterface $fieldHandler): FieldDefinition|null + public function process( + QueryFieldDescriptor $queryFieldDescriptor, + FieldHandlerInterface $fieldHandler, + ): FieldDefinition|null { $annotations = $queryFieldDescriptor->getMiddlewareAnnotations(); /** @var Security[] $securityAnnotations */ @@ -116,24 +120,6 @@ private function getVariables(array $args, array $parameters, object|null $sourc $argsName = array_keys($parameters); $argsByName = array_combine($argsName, $args); - assert(is_array($argsByName)); - - /*if ($diff = array_intersect(array_keys($variables), array_keys($argsName))) { - foreach ($diff as $key => $variableName) { - if ($variables[$variableName] !== $argsByName[$variableName]) { - continue; - } - - unset($diff[$key]); - } - - if ($diff) { - $singular = count($diff) === 1; - if ($this->logger !== null) { - $this->logger->warning(sprintf('Controller argument%s "%s" collided with the built-in security expression variables. The built-in value%s are being used for the @Security expression.', $singular ? '' : 's', implode('", "', $diff), $singular ? 's' : '')); - } - } - }*/ return $variables + $argsByName; } diff --git a/src/Middlewares/SecurityInputFieldMiddleware.php b/src/Middlewares/SecurityInputFieldMiddleware.php index 9eaba0b626..c2e77c207b 100644 --- a/src/Middlewares/SecurityInputFieldMiddleware.php +++ b/src/Middlewares/SecurityInputFieldMiddleware.php @@ -15,8 +15,6 @@ use function array_combine; use function array_keys; -use function assert; -use function is_array; /** * A field input middleware that reads "Security" Symfony annotations. @@ -85,7 +83,6 @@ private function getVariables(array $args, array $parameters, object|null $sourc $argsName = array_keys($parameters); $argsByName = array_combine($argsName, $args); - assert(is_array($argsByName)); return $variables + $argsByName; } diff --git a/src/Schema.php b/src/Schema.php index 2e53d3c1c7..f90c7fbbff 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -11,8 +11,6 @@ use TheCodingMachine\GraphQLite\Mappers\Root\RootTypeMapperInterface; use TheCodingMachine\GraphQLite\Types\TypeResolver; -use function assert; - /** * A GraphQL schema that takes into constructor argument a QueryProvider. * @@ -112,9 +110,7 @@ public function __construct( return $subscription; } - $type = $rootTypeMapper->mapNameToType($name); - assert($type instanceof Type); - return $type; + return $rootTypeMapper->mapNameToType($name); }); $typeResolver->registerSchema($this); diff --git a/src/Types/EnumType.php b/src/Types/EnumType.php index 9e48f98d22..25ff52af43 100644 --- a/src/Types/EnumType.php +++ b/src/Types/EnumType.php @@ -19,7 +19,7 @@ class EnumType extends BaseEnumType { /** * @param class-string $enumName - * @param array $caseDescriptions + * @param array $caseDescriptions * @param array $caseDeprecationReasons */ public function __construct( diff --git a/src/Types/ID.php b/src/Types/ID.php index 950214a4ed..64cf0599c4 100644 --- a/src/Types/ID.php +++ b/src/Types/ID.php @@ -7,7 +7,6 @@ use InvalidArgumentException; use function is_bool; -use function is_object; use function is_scalar; use function method_exists; @@ -21,7 +20,7 @@ class ID */ public function __construct(private readonly bool|float|int|string|object $value) { - if (! is_scalar($value) && (! is_object($value) || ! method_exists($value, '__toString'))) { + if (! is_scalar($value) && ! method_exists($value, '__toString')) { throw new InvalidArgumentException('ID constructor cannot be passed a non scalar value.'); } } diff --git a/src/Types/TypeAnnotatedObjectType.php b/src/Types/TypeAnnotatedObjectType.php index 9954c4daef..0fcf296432 100644 --- a/src/Types/TypeAnnotatedObjectType.php +++ b/src/Types/TypeAnnotatedObjectType.php @@ -61,7 +61,6 @@ public static function createFromAnnotatedClass(string $typeName, string $classN // FIXME: add an interface with a @Type that is implemented by noone. // Check that it does not trigger an exception. - /** @var array> $interfaces */ $interfaces = class_implements($className); foreach ($interfaces as $interface) { if (! $recursiveTypeMapper->canMapClassToType($interface)) { diff --git a/tests/Integration/EndToEndTest.php b/tests/Integration/EndToEndTest.php index 24a055dd1a..2423be089d 100644 --- a/tests/Integration/EndToEndTest.php +++ b/tests/Integration/EndToEndTest.php @@ -29,7 +29,6 @@ use TheCodingMachine\GraphQLite\Loggers\ExceptionLogger; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException; use TheCodingMachine\GraphQLite\Middlewares\MissingAuthorizationException; -use TheCodingMachine\GraphQLite\Middlewares\PrefetchFieldMiddleware; use TheCodingMachine\GraphQLite\Schema; use TheCodingMachine\GraphQLite\SchemaFactory; use TheCodingMachine\GraphQLite\Security\AuthenticationServiceInterface; From 436e69e3b6c5cb2db181ac1d56ace66fc9876fe6 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Wed, 18 Dec 2024 08:22:03 -0500 Subject: [PATCH 096/108] Fixed incorrect assertions (#723) --- src/Mappers/Parameters/TypeHandler.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Mappers/Parameters/TypeHandler.php b/src/Mappers/Parameters/TypeHandler.php index 393368fa2e..cd1a2d6b5e 100644 --- a/src/Mappers/Parameters/TypeHandler.php +++ b/src/Mappers/Parameters/TypeHandler.php @@ -96,7 +96,7 @@ public function mapReturnType( $refMethod, $docBlockObj, ); - assert(! $type instanceof InputType); + assert($type instanceof OutputType); } catch (CannotMapTypeExceptionInterface $e) { $e->addReturnInfo($refMethod); throw $e; @@ -160,7 +160,12 @@ private function getDocBlockPropertyType(DocBlock $docBlock, ReflectionProperty return reset($varTags)->getType(); } - public function mapParameter(ReflectionParameter $parameter, DocBlock $docBlock, Type|null $paramTagType, ParameterAnnotations $parameterAnnotations): ParameterInterface + public function mapParameter( + ReflectionParameter $parameter, + DocBlock $docBlock, + Type|null $paramTagType, + ParameterAnnotations $parameterAnnotations, + ): ParameterInterface { $hideParameter = $parameterAnnotations->getAnnotationByType(HideParameter::class); if ($hideParameter) { @@ -327,7 +332,7 @@ public function mapInputProperty( $inputType = $this->typeResolver->mapNameToInputType($inputTypeName); } else { $inputType = $this->mapPropertyType($refProperty, $docBlock, true, $argumentName, $isNullable); - assert(! $inputType instanceof OutputType); + assert($inputType instanceof InputType); } $hasDefault = $defaultValue !== null || $isNullable; From 37c06a9ee307980fd6be0f423d789412eda7c9f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Dec 2024 14:49:15 -0500 Subject: [PATCH 097/108] Bump codecov/codecov-action from 5.1.1 to 5.1.2 (#724) --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index e7d5c579ba..a64e09d68e 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -84,7 +84,7 @@ jobs: path: "build" overwrite: true - - uses: codecov/codecov-action@v5.1.1 # upload the coverage to codecov + - uses: codecov/codecov-action@v5.1.2 # upload the coverage to codecov with: fail_ci_if_error: false # optional (default = false) - Need CODECOV_TOKEN # Do not upload in forks, and only on php8.3, latest deps From 434b7ea39176a27f69c24ab0086db1c9cfa4cda7 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Tue, 11 Mar 2025 22:40:57 -0400 Subject: [PATCH 098/108] Houskeeping (#736) * CS fixes * Resolved additional phpunit issues and deprecations * Allow PHPUnit 10.x * Restore deprecated ReflectionMethod construction for - + + ./tests/ ./tests/Bootstrap.php + + src/ diff --git a/src/Annotations/EnumType.php b/src/Annotations/EnumType.php index 77f624bd6e..e83431fbaf 100644 --- a/src/Annotations/EnumType.php +++ b/src/Annotations/EnumType.php @@ -20,11 +20,8 @@ #[Attribute(Attribute::TARGET_CLASS)] class EnumType { - /** @var string|null */ - private $name; - - /** @var bool */ - private $useValues; + private string|null $name; + private bool $useValues; /** @param mixed[] $attributes */ public function __construct(array $attributes = [], string|null $name = null, bool|null $useValues = null) diff --git a/src/Annotations/ExtendType.php b/src/Annotations/ExtendType.php index 609c141138..55831b23ea 100644 --- a/src/Annotations/ExtendType.php +++ b/src/Annotations/ExtendType.php @@ -19,13 +19,15 @@ class ExtendType { /** @var class-string|null */ - private $class; - /** @var string|null */ - private $name; + private string|null $class; + private string|null $name; /** @param mixed[] $attributes */ - public function __construct(array $attributes = [], string|null $class = null, string|null $name = null) - { + public function __construct( + array $attributes = [], + string|null $class = null, + string|null $name = null, + ) { $className = isset($attributes['class']) ? ltrim($attributes['class'], '\\') : null; $className = $className ?? $class; if ($className !== null && ! class_exists($className) && ! interface_exists($className)) { diff --git a/src/Annotations/Factory.php b/src/Annotations/Factory.php index 9d617e4744..6032d8dddb 100644 --- a/src/Annotations/Factory.php +++ b/src/Annotations/Factory.php @@ -13,10 +13,8 @@ #[Attribute(Attribute::TARGET_METHOD)] class Factory { - /** @var string|null */ - private $name; - /** @var bool */ - private $default; + private string|null $name; + private bool $default; /** @param mixed[] $attributes */ public function __construct(array $attributes = [], string|null $name = null, bool|null $default = null) diff --git a/src/Annotations/FailWith.php b/src/Annotations/FailWith.php index 9e87eb0d80..b647908ae2 100644 --- a/src/Annotations/FailWith.php +++ b/src/Annotations/FailWith.php @@ -15,10 +15,8 @@ class FailWith implements MiddlewareAnnotationInterface { /** * The default value to use if the right is not enforced. - * - * @var mixed */ - private $value; + private mixed $value; /** @throws BadMethodCallException */ public function __construct(mixed $values = [], mixed $value = '__fail__with__magic__key__') diff --git a/src/Annotations/Field.php b/src/Annotations/Field.php index 12712bd6e8..82044c5d29 100644 --- a/src/Annotations/Field.php +++ b/src/Annotations/Field.php @@ -13,28 +13,31 @@ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class Field extends AbstractRequest { - /** @var string|null */ - private $prefetchMethod; + private string|null $prefetchMethod; /** * Input/Output type names for which this fields should be applied to. * * @var string[]|null */ - private $for = null; + private array|null $for = null; - /** @var string|null */ - private $description; - - /** @var string|null */ - private $inputType; + private string|null $description; + private string|null $inputType; /** * @param mixed[] $attributes * @param string|string[] $for */ - public function __construct(array $attributes = [], string|null $name = null, string|null $outputType = null, string|null $prefetchMethod = null, string|array|null $for = null, string|null $description = null, string|null $inputType = null) - { + public function __construct( + array $attributes = [], + string|null $name = null, + string|null $outputType = null, + string|null $prefetchMethod = null, + string|array|null $for = null, + string|null $description = null, + string|null $inputType = null, + ) { parent::__construct($attributes, $name, $outputType); $this->prefetchMethod = $prefetchMethod ?? $attributes['prefetchMethod'] ?? null; diff --git a/src/Annotations/MagicField.php b/src/Annotations/MagicField.php index 650143ff2e..306bddd92c 100644 --- a/src/Annotations/MagicField.php +++ b/src/Annotations/MagicField.php @@ -16,44 +16,56 @@ #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] class MagicField implements SourceFieldInterface { - /** @var string */ - private $name; + private string $name; + private string|null $outputType; + private string|null $phpType; + private string|null $description; + private string|null $sourceName; - /** @var string|null */ - private $outputType; - - /** @var string|null */ - private $phpType; - - /** @var string|null */ - private $description; - - /** @var string|null */ - private $sourceName; - - /** @var MiddlewareAnnotations */ - private $middlewareAnnotations; + private MiddlewareAnnotations $middlewareAnnotations; /** @var array */ - private $parameterAnnotations; + private array $parameterAnnotations; /** * @param mixed[] $attributes * @param array $annotations */ - public function __construct(array $attributes = [], string|null $name = null, string|null $outputType = null, string|null $phpType = null, string|null $description = null, string|null $sourceName = null, array $annotations = []) - { - $this->name = $attributes['name'] ?? $name; + public function __construct( + array $attributes = [], + string|null $name = null, + string|null $outputType = null, + string|null $phpType = null, + string|null $description = null, + string|null $sourceName = null, + array $annotations = [], + ) { + $name = $attributes['name'] ?? $name; + if (! $name) { + throw new BadMethodCallException( + 'The #[MagicField] attribute must be passed a name. For instance: #[MagicField(name: "phone")]', + ); + } + + $this->name = $name; $this->outputType = $attributes['outputType'] ?? $outputType ?? null; $this->phpType = $attributes['phpType'] ?? $phpType ?? null; $this->description = $attributes['description'] ?? $description ?? null; $this->sourceName = $attributes['sourceName'] ?? $sourceName ?? null; - if (! $this->name || (! $this->outputType && ! $this->phpType)) { - throw new BadMethodCallException('The #[MagicField] attribute must be passed a name and an output type or a php type. For instance: "#[MagicField(name: \'phone\', outputType: \'String!\')]" or "#[MagicField(name: \'phone\', phpType: \'string\')]"'); + if (! $this->outputType && ! $this->phpType) { + throw new BadMethodCallException( + "The #[MagicField] attribute must be passed an output type or a php type. + For instance: #[MagicField(name: 'phone', outputType: 'String!')] + or #[MagicField(name: 'phone', phpType: 'string')]", + ); } if (isset($this->outputType) && $this->phpType) { - throw new BadMethodCallException('In a #[MagicField] attribute, you cannot use the outputType and the phpType at the same time. For instance: "#[MagicField(name: \'phone\', outputType: \'String!\')]" or "#[MagicField(name: \'phone\', phpType: \'string\')]"'); + throw new BadMethodCallException( + "In a #[MagicField] attribute, you cannot use the outputType and the phpType at the + same time. For instance: #[MagicField(name: 'phone', outputType: 'String!')] + or #[MagicField(name: 'phone', phpType: 'string')]", + ); } $middlewareAnnotations = []; $parameterAnnotations = []; @@ -67,13 +79,18 @@ public function __construct(array $attributes = [], string|null $name = null, st } elseif ($annotation instanceof ParameterAnnotationInterface) { $parameterAnnotations[$annotation->getTarget()][] = $annotation; } else { - throw new BadMethodCallException('The #[MagicField] attribute\'s "annotations" attribute must be passed an array of annotations implementing either MiddlewareAnnotationInterface or ParameterAnnotationInterface."'); + throw new BadMethodCallException( + "The #[MagicField] attribute's 'annotation' attribute must be passed an array + of annotations implementing either MiddlewareAnnotationInterface or + ParameterAnnotationInterface.", + ); } } $this->middlewareAnnotations = new MiddlewareAnnotations($middlewareAnnotations); - $this->parameterAnnotations = array_map(static function (array $parameterAnnotationsForAttribute): ParameterAnnotations { - return new ParameterAnnotations($parameterAnnotationsForAttribute); - }, $parameterAnnotations); + $this->parameterAnnotations = array_map( + static fn (array $parameterAnnotationsForAttribute): ParameterAnnotations => new ParameterAnnotations($parameterAnnotationsForAttribute), + $parameterAnnotations, + ); } /** diff --git a/src/Annotations/Right.php b/src/Annotations/Right.php index d14597a326..63f5a5b380 100644 --- a/src/Annotations/Right.php +++ b/src/Annotations/Right.php @@ -12,8 +12,7 @@ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class Right implements MiddlewareAnnotationInterface { - /** @var string */ - private $name; + private string $name; /** * @param array|string $name diff --git a/src/Annotations/Security.php b/src/Annotations/Security.php index 6e334e3e45..13db0c93db 100644 --- a/src/Annotations/Security.php +++ b/src/Annotations/Security.php @@ -13,33 +13,35 @@ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class Security implements MiddlewareAnnotationInterface { - /** @var string */ - private $expression; - /** @var mixed */ - private $failWith; - /** @var bool */ - private $failWithIsSet = false; - /** @var int */ - private $statusCode; - /** @var string */ - private $message; + private string $expression; + private mixed $failWith; + private bool $failWithIsSet = false; + private int $statusCode; + private string $message; /** * @param array|string $data data array managed by the Doctrine Annotations library or the expression * * @throws BadMethodCallException */ - public function __construct(array|string $data = [], string|null $expression = null, mixed $failWith = '__fail__with__magic__key__', string|null $message = null, int|null $statusCode = null) - { + public function __construct( + array|string $data = [], + string|null $expression = null, + mixed $failWith = '__fail__with__magic__key__', + string|null $message = null, + int|null $statusCode = null, + ) { if (is_string($data)) { $data = ['expression' => $data]; } - $this->expression = $data['value'] ?? $data['expression'] ?? $expression; - if (! $this->expression) { + $expression = $data['value'] ?? $data['expression'] ?? $expression; + if (! $expression) { throw new BadMethodCallException('The #[Security] attribute must be passed an expression. For instance: "#[Security("is_granted(\'CAN_EDIT_STUFF\')")]"'); } + $this->expression = $expression; + if (array_key_exists('failWith', $data)) { $this->failWith = $data['failWith']; $this->failWithIsSet = true; diff --git a/tests/AnnotationReaderTest.php b/tests/AnnotationReaderTest.php index f7493dead9..36e1a1f220 100644 --- a/tests/AnnotationReaderTest.php +++ b/tests/AnnotationReaderTest.php @@ -9,15 +9,12 @@ use ReflectionMethod; use TheCodingMachine\GraphQLite\Annotations\Autowire; use TheCodingMachine\GraphQLite\Annotations\Exceptions\ClassNotFoundException; -use TheCodingMachine\GraphQLite\Annotations\Exceptions\InvalidParameterException; use TheCodingMachine\GraphQLite\Annotations\Field; use TheCodingMachine\GraphQLite\Annotations\Security; use TheCodingMachine\GraphQLite\Annotations\Type; use TheCodingMachine\GraphQLite\Fixtures\Annotations\ClassWithInvalidClassAnnotation; use TheCodingMachine\GraphQLite\Fixtures\Annotations\ClassWithInvalidExtendTypeAnnotation; use TheCodingMachine\GraphQLite\Fixtures\Annotations\ClassWithInvalidTypeAnnotation; -use TheCodingMachine\GraphQLite\Fixtures\Annotations\ClassWithTargetMethodParameterAnnotation; -use TheCodingMachine\GraphQLite\Fixtures\Annotations\TargetMethodParameterAnnotation; use TheCodingMachine\GraphQLite\Fixtures\Attributes\TestType; class AnnotationReaderTest extends TestCase @@ -26,7 +23,10 @@ public function testBadAnnotation(): void { $annotationReader = new AnnotationReader(); - $type = $annotationReader->getTypeAnnotation(new ReflectionClass(ClassWithInvalidClassAnnotation::class)); + $type = $annotationReader->getTypeAnnotation( + new ReflectionClass(ClassWithInvalidClassAnnotation::class), + ); + $this->assertNull($type); } @@ -34,14 +34,20 @@ public function testSmellyAnnotation(): void { $annotationReader = new AnnotationReader(); - $this->assertNull($annotationReader->getTypeAnnotation(new ReflectionClass(ClassWithInvalidTypeAnnotation::class))); + $this->assertNull($annotationReader->getTypeAnnotation( + new ReflectionClass(ClassWithInvalidTypeAnnotation::class)), + ); } public function testGetAnnotationsWithBadAnnotation(): void { $annotationReader = new AnnotationReader(); - $types = $annotationReader->getClassAnnotations(new ReflectionClass(ClassWithInvalidClassAnnotation::class), Type::class); + $types = $annotationReader->getClassAnnotations( + new ReflectionClass(ClassWithInvalidClassAnnotation::class), + Type::class, + ); + $this->assertSame([], $types); } @@ -49,7 +55,10 @@ public function testMethodWithBadAnnotation(): void { $annotationReader = new AnnotationReader(); - $type = $annotationReader->getRequestAnnotation(new ReflectionMethod(ClassWithInvalidClassAnnotation::class, 'testMethod'), Field::class); + $type = $annotationReader->getRequestAnnotation( + new ReflectionMethod(ClassWithInvalidClassAnnotation::class, 'testMethod'), + Field::class, + ); $this->assertNull($type); } @@ -59,14 +68,20 @@ public function testExtendAnnotationException(): void $this->expectException(ClassNotFoundException::class); $this->expectExceptionMessage("Could not autoload class 'foo' defined in #[ExtendType] attribute of class 'TheCodingMachine\GraphQLite\Fixtures\Annotations\ClassWithInvalidExtendTypeAnnotation'"); - $annotationReader->getExtendTypeAnnotation(new ReflectionClass(ClassWithInvalidExtendTypeAnnotation::class)); + $annotationReader->getExtendTypeAnnotation( + new ReflectionClass(ClassWithInvalidExtendTypeAnnotation::class), + ); } public function testMethodsWithBadAnnotation(): void { $annotationReader = new AnnotationReader(); - $type = $annotationReader->getMethodAnnotations(new ReflectionMethod(ClassWithInvalidClassAnnotation::class, 'testMethod'), Field::class); + $type = $annotationReader->getMethodAnnotations( + new ReflectionMethod(ClassWithInvalidClassAnnotation::class, 'testMethod'), + Field::class, + ); + $this->assertSame([], $type); } @@ -102,7 +117,11 @@ public function testPhp8AttributeMethodAnnotation(): void { $annotationReader = new AnnotationReader(); - $type = $annotationReader->getRequestAnnotation(new ReflectionMethod(TestType::class, 'getField'), Field::class); + $type = $annotationReader->getRequestAnnotation( + new ReflectionMethod(TestType::class, 'getField'), + Field::class, + ); + $this->assertInstanceOf(Field::class, $type); } @@ -110,7 +129,9 @@ public function testPhp8AttributeMethodAnnotations(): void { $annotationReader = new AnnotationReader(); - $middlewareAnnotations = $annotationReader->getMiddlewareAnnotations(new ReflectionMethod(TestType::class, 'getField')); + $middlewareAnnotations = $annotationReader->getMiddlewareAnnotations( + new ReflectionMethod(TestType::class, 'getField'), + ); /** @var Security[] $securitys */ $securitys = $middlewareAnnotations->getAnnotationsByType(Security::class); @@ -124,34 +145,14 @@ public function testPhp8AttributeParameterAnnotations(): void { $annotationReader = new AnnotationReader(); - $parameterAnnotations = $annotationReader->getParameterAnnotationsPerParameter((new ReflectionMethod(self::class, 'method1'))->getParameters()); - - $this->assertInstanceOf(Autowire::class, $parameterAnnotations['dao']->getAnnotationByType(Autowire::class)); - } - - /** - * This functionality can be dropped with next major release (8.0) with added explicit deprecations before release. - */ - public function testPhp8AttributeParameterAnnotationsForTargetMethod(): void - { - $annotationReader = new AnnotationReader(); - - $parameterAnnotations = $annotationReader->getParameterAnnotationsPerParameter((new ReflectionMethod(ClassWithTargetMethodParameterAnnotation::class, 'method'))->getParameters()); - - $this->assertInstanceOf(TargetMethodParameterAnnotation::class, $parameterAnnotations['bar']->getAnnotationByType(TargetMethodParameterAnnotation::class)); - } - - /** - * This functionality can be dropped with next major release (8.0) with added explicit deprecations before release. - */ - public function testPhp8AttributeParameterAnnotationsForTargetMethodWithInvalidTargetParameter(): void - { - $annotationReader = new AnnotationReader(); - - $this->expectException(InvalidParameterException::class); - $this->expectExceptionMessage('Parameter "unexistent" declared in annotation "TheCodingMachine\GraphQLite\Fixtures\Annotations\TargetMethodParameterAnnotation" of method "TheCodingMachine\GraphQLite\Fixtures\Annotations\ClassWithTargetMethodParameterAnnotation::methodWithInvalidAnnotation()" does not exist.'); + $parameterAnnotations = $annotationReader->getParameterAnnotationsPerParameter( + (new ReflectionMethod(self::class, 'method1'))->getParameters(), + ); - $annotationReader->getParameterAnnotationsPerParameter((new ReflectionMethod(ClassWithTargetMethodParameterAnnotation::class, 'methodWithInvalidAnnotation'))->getParameters()); + $this->assertInstanceOf( + Autowire::class, + $parameterAnnotations['dao']->getAnnotationByType(Autowire::class), + ); } /** @noinspection PhpUnusedPrivateMethodInspection Used in {@see testPhp8AttributeParameterAnnotations} */ diff --git a/tests/Annotations/SecurityTest.php b/tests/Annotations/SecurityTest.php index a35275092f..3f82ae129d 100644 --- a/tests/Annotations/SecurityTest.php +++ b/tests/Annotations/SecurityTest.php @@ -4,8 +4,6 @@ use BadMethodCallException; use PHPUnit\Framework\TestCase; -use stdClass; -use TypeError; class SecurityTest extends TestCase { diff --git a/tests/Fixtures/Annotations/ClassWithTargetMethodParameterAnnotation.php b/tests/Fixtures/Annotations/ClassWithTargetMethodParameterAnnotation.php deleted file mode 100644 index 6830f2bbdf..0000000000 --- a/tests/Fixtures/Annotations/ClassWithTargetMethodParameterAnnotation.php +++ /dev/null @@ -1,21 +0,0 @@ -target; - } -} From d17c80a37bab07f192ba93843618d2c5ec911145 Mon Sep 17 00:00:00 2001 From: Jaap van Otterdijk Date: Mon, 24 Mar 2025 19:19:17 +0100 Subject: [PATCH 099/108] Fix issue with kept deprecation reason (#735) When a type contains multiple fields and one of them is deprecated the following fields will be marked as deprecated. By resetting the value in each run we make sure the value is not kept. --- src/FieldsBuilder.php | 1 + tests/FieldsBuilderTest.php | 18 ++++++++++++++++ tests/Fixtures/TestDeprecatedField.php | 16 ++++++++++++++ .../TestObjectWithDeprecatedField.php | 21 +++++++++++++++++++ 4 files changed, 56 insertions(+) create mode 100644 tests/Fixtures/TestDeprecatedField.php create mode 100644 tests/Fixtures/TestObjectWithDeprecatedField.php diff --git a/src/FieldsBuilder.php b/src/FieldsBuilder.php index 10b0c620ae..c0dc7d27d1 100644 --- a/src/FieldsBuilder.php +++ b/src/FieldsBuilder.php @@ -689,6 +689,7 @@ private function getQueryFieldsFromSourceFields( $docBlockComment = rtrim($docBlockObj->getSummary() . "\n" . $docBlockObj->getDescription()->render()); $deprecated = $docBlockObj->getTagsByName('deprecated'); + $deprecationReason = null; if (count($deprecated) >= 1) { $deprecationReason = trim((string) $deprecated[0]); } diff --git a/tests/FieldsBuilderTest.php b/tests/FieldsBuilderTest.php index 0cd11d0d49..c280218fec 100644 --- a/tests/FieldsBuilderTest.php +++ b/tests/FieldsBuilderTest.php @@ -37,11 +37,13 @@ use TheCodingMachine\GraphQLite\Fixtures\TestControllerWithParamDateTime; use TheCodingMachine\GraphQLite\Fixtures\TestControllerWithReturnDateTime; use TheCodingMachine\GraphQLite\Fixtures\TestControllerWithUnionInputParam; +use TheCodingMachine\GraphQLite\Fixtures\TestDeprecatedField; use TheCodingMachine\GraphQLite\Fixtures\TestDoubleReturnTag; use TheCodingMachine\GraphQLite\Fixtures\TestEnum; use TheCodingMachine\GraphQLite\Fixtures\TestFieldBadInputType; use TheCodingMachine\GraphQLite\Fixtures\TestFieldBadOutputType; use TheCodingMachine\GraphQLite\Fixtures\TestObject; +use TheCodingMachine\GraphQLite\Fixtures\TestObjectWithDeprecatedField; use TheCodingMachine\GraphQLite\Fixtures\TestSelfType; use TheCodingMachine\GraphQLite\Fixtures\TestSourceFieldBadOutputType; use TheCodingMachine\GraphQLite\Fixtures\TestSourceFieldBadOutputType2; @@ -922,4 +924,20 @@ public function testSourceNameInSourceAndMagicFields(): void $result = $resolve($source, [], null, $this->createMock(ResolveInfo::class)); $this->assertSame('bar value', $result); } + + public function testDeprecationInDocblock(): void + { + $fieldsBuilder = $this->buildFieldsBuilder(); + $inputFields = $fieldsBuilder->getFields( + new TestDeprecatedField(), + 'Test', + ); + + $this->assertCount(2, $inputFields); + + $this->assertEquals('this is deprecated', $inputFields['deprecatedField']->deprecationReason); + $this->assertTrue( $inputFields['deprecatedField']->isDeprecated()); + $this->assertNull( $inputFields['name']->deprecationReason); + $this->assertFalse( $inputFields['name']->isDeprecated()); + } } diff --git a/tests/Fixtures/TestDeprecatedField.php b/tests/Fixtures/TestDeprecatedField.php new file mode 100644 index 0000000000..c417836ceb --- /dev/null +++ b/tests/Fixtures/TestDeprecatedField.php @@ -0,0 +1,16 @@ + Date: Tue, 25 Mar 2025 22:47:26 -0400 Subject: [PATCH 100/108] Bump codecov/codecov-action from 5.1.2 to 5.4.0 (#732) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.1.2 to 5.4.0. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v5.1.2...v5.4.0) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index a64e09d68e..67525172fe 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -84,7 +84,7 @@ jobs: path: "build" overwrite: true - - uses: codecov/codecov-action@v5.1.2 # upload the coverage to codecov + - uses: codecov/codecov-action@v5.4.0 # upload the coverage to codecov with: fail_ci_if_error: false # optional (default = false) - Need CODECOV_TOKEN # Do not upload in forks, and only on php8.3, latest deps From f99dfb8dd6aae22be84fba298b7c40174a80f72e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Mar 2025 22:50:34 -0400 Subject: [PATCH 101/108] Bump JamesIves/github-pages-deploy-action from 4.7.2 to 4.7.3 (#730) Bumps [JamesIves/github-pages-deploy-action](https://github.com/jamesives/github-pages-deploy-action) from 4.7.2 to 4.7.3. - [Release notes](https://github.com/jamesives/github-pages-deploy-action/releases) - [Commits](https://github.com/jamesives/github-pages-deploy-action/compare/v4.7.2...v4.7.3) --- updated-dependencies: - dependency-name: JamesIves/github-pages-deploy-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/doc_generation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/doc_generation.yml b/.github/workflows/doc_generation.yml index e2a2abaf92..aeeb34dba5 100644 --- a/.github/workflows/doc_generation.yml +++ b/.github/workflows/doc_generation.yml @@ -36,7 +36,7 @@ jobs: - name: "Deploy website" if: "${{ github.event_name == 'push' || github.event_name == 'release' }}" - uses: JamesIves/github-pages-deploy-action@v4.7.2 + uses: JamesIves/github-pages-deploy-action@v4.7.3 with: token: ${{ secrets.GITHUB_TOKEN }} branch: gh-pages # The branch the action should deploy to. From 6e36bfda5f7d6c255a43daf85a4647b65bc47b06 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Mar 2025 23:56:41 -0400 Subject: [PATCH 102/108] Update doctrine/coding-standard requirement from ^12.0 to ^12.0 || ^13.0 (#741) * Update doctrine/coding-standard requirement from ^12.0 to ^12.0 || ^13.0 Updates the requirements on [doctrine/coding-standard](https://github.com/doctrine/coding-standard) to permit the latest version. - [Release notes](https://github.com/doctrine/coding-standard/releases) - [Commits](https://github.com/doctrine/coding-standard/compare/12.0.0...13.0.0) --- updated-dependencies: - dependency-name: doctrine/coding-standard dependency-type: direct:development ... Signed-off-by: dependabot[bot] * Run PHPCS on the target PHP version * Specify phpcs vendor/bin path * Only run CS checks on the minimum supported version * Resolve duplicate code coverage artifact name collision --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jacob Thomason --- .github/workflows/continuous_integration.yml | 13 +++++++------ composer.json | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 67525172fe..49638e7e49 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -74,21 +74,22 @@ jobs: - name: "Run static code analysis with phpstan/phpstan" run: "composer phpstan" - - name: "Run coding standard checks with squizlabs/php_codesniffer" - run: "composer cs-check" + - name: "Run coding standard checks with squizlabs/php_codesniffer on minimum supported PHP version" + if: matrix.php-version == '8.1' + run: composer cs-check - name: "Archive code coverage results" uses: actions/upload-artifact@v4 with: - name: codeCoverage-${{ matrix.php-version }} + name: codeCoverage-${{ matrix.php-version }}-${{ github.run_id }} path: "build" overwrite: true - uses: codecov/codecov-action@v5.4.0 # upload the coverage to codecov with: fail_ci_if_error: false # optional (default = false) - Need CODECOV_TOKEN - # Do not upload in forks, and only on php8.3, latest deps - if: ${{ github.repository == 'thecodingmachine/graphqlite' && matrix.php-version == '8.3' && matrix.install-args == '' }} + # Do not upload in forks, and only on php8.4, latest deps + if: ${{ github.repository == 'thecodingmachine/graphqlite' && matrix.php-version == '8.4' && matrix.install-args == '' }} examples: name: Check Examples @@ -103,7 +104,7 @@ jobs: - name: "Install PHP with extensions" uses: "shivammathur/setup-php@v2" with: - php-version: "8.2" + php-version: "8.4" tools: composer:v2 - name: "Install dependencies with composer" working-directory: "examples/${{ matrix.example }}" diff --git a/composer.json b/composer.json index 33751a0af7..15e98ee2bd 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ }, "require-dev": { "beberlei/porpaginas": "^2.0", - "doctrine/coding-standard": "^12.0", + "doctrine/coding-standard": "^12.0 || ^13.0", "ecodev/graphql-upload": "^7.0", "laminas/laminas-diactoros": "^3.5", "myclabs/php-enum": "^1.6.6", From d64cb997b838421c7b452a49e7ecdbe02d8d9ba1 Mon Sep 17 00:00:00 2001 From: Oleksandr Prypkhan Date: Fri, 6 Jun 2025 18:03:12 +0300 Subject: [PATCH 103/108] Allow callable() as field deferring mechanism (#739) * Allow callable() as field deferring mechanism * Improve coverage * Bump dependencies for proper callable() type support * Revert deferred test, add a separate one for callable --- composer.json | 4 +- src/Mappers/CannotMapTypeException.php | 20 ++- src/Mappers/Parameters/TypeHandler.php | 2 + src/Mappers/Root/CallableTypeMapper.php | 61 ++++++++ src/QueryField.php | 10 ++ src/SchemaFactory.php | 2 + tests/AbstractQueryProvider.php | 2 + tests/Fixtures/Integration/Models/Blog.php | 6 + tests/Integration/EndToEndTest.php | 9 ++ tests/Integration/IntegrationTestCase.php | 12 +- tests/Mappers/Root/CallableTypeMapperTest.php | 131 ++++++++++++++++++ tests/QueryFieldTest.php | 20 +++ website/docs/type-mapping.mdx | 15 +- 13 files changed, 279 insertions(+), 15 deletions(-) create mode 100644 src/Mappers/Root/CallableTypeMapper.php create mode 100644 tests/Mappers/Root/CallableTypeMapperTest.php diff --git a/composer.json b/composer.json index 15e98ee2bd..520f02bbc5 100644 --- a/composer.json +++ b/composer.json @@ -13,8 +13,8 @@ "php": ">=8.1", "ext-json": "*", "composer/package-versions-deprecated": "^1.8", - "phpdocumentor/reflection-docblock": "^4.3 || ^5.0", - "phpdocumentor/type-resolver": "^1.4", + "phpdocumentor/reflection-docblock": "^5.4", + "phpdocumentor/type-resolver": "^1.7", "psr/container": "^1.1 || ^2", "psr/http-factory": "^1", "psr/http-message": "^1.0.1 || ^2.0", diff --git a/src/Mappers/CannotMapTypeException.php b/src/Mappers/CannotMapTypeException.php index 7ea34ea8cf..aa1331747d 100644 --- a/src/Mappers/CannotMapTypeException.php +++ b/src/Mappers/CannotMapTypeException.php @@ -13,6 +13,7 @@ use GraphQL\Type\Definition\Type; use phpDocumentor\Reflection\Type as PhpDocumentorType; use phpDocumentor\Reflection\Types\Array_; +use phpDocumentor\Reflection\Types\Callable_; use phpDocumentor\Reflection\Types\Iterable_; use phpDocumentor\Reflection\Types\Mixed_; use phpDocumentor\Reflection\Types\Object_; @@ -124,7 +125,7 @@ public static function extendTypeWithBadTargetedClass(string $className, ExtendT return new self('For ' . self::extendTypeToString($extendType) . ' annotation declared in class "' . $className . '", the pointed at GraphQL type cannot be extended. You can only target types extending the MutableObjectType (like types created with the @Type annotation).'); } - /** @param Array_|Iterable_|Object_|Mixed_ $type */ + /** @param Array_|Iterable_|Object_|Mixed_|Callable_ $type */ public static function createForMissingPhpDoc(PhpDocumentorType $type, ReflectionMethod|ReflectionProperty $reflector, string|null $argumentName = null): self { $typeStr = ''; @@ -136,6 +137,8 @@ public static function createForMissingPhpDoc(PhpDocumentorType $type, Reflectio $typeStr = sprintf('object ("%s")', $type->getFqsen()); } elseif ($type instanceof Mixed_) { $typeStr = 'mixed'; + } elseif ($type instanceof Callable_) { + $typeStr = 'callable'; } assert($typeStr !== ''); if ($argumentName === null) { @@ -177,4 +180,19 @@ public static function createForNonNullReturnByTypeMapper(): self { return new self('a type mapper returned a GraphQL\Type\Definition\NonNull instance. All instances returned by type mappers should be nullable. It is the role of the NullableTypeMapperAdapter class to make a GraphQL type in a "NonNull". Note: this is an error in the TypeMapper code or in GraphQLite itself. Please check your custom type mappers or open an issue on GitHub if you don\'t have any custom type mapper.'); } + + public static function createForUnexpectedCallableParameters(): self + { + return new self('callable() type-hint must not specify any parameters.'); + } + + public static function createForMissingCallableReturnType(): self + { + return new self('callable() type-hint must specify its return type. For instance: callable(): int'); + } + + public static function createForCallableAsInput(): self + { + return new self('callable() type-hint can only be used as output type.'); + } } diff --git a/src/Mappers/Parameters/TypeHandler.php b/src/Mappers/Parameters/TypeHandler.php index cd1a2d6b5e..77fdad3f55 100644 --- a/src/Mappers/Parameters/TypeHandler.php +++ b/src/Mappers/Parameters/TypeHandler.php @@ -16,6 +16,7 @@ use phpDocumentor\Reflection\Type; use phpDocumentor\Reflection\TypeResolver as PhpDocumentorTypeResolver; use phpDocumentor\Reflection\Types\Array_; +use phpDocumentor\Reflection\Types\Callable_; use phpDocumentor\Reflection\Types\Collection; use phpDocumentor\Reflection\Types\Compound; use phpDocumentor\Reflection\Types\Iterable_; @@ -375,6 +376,7 @@ private function mapType( $innerType instanceof Array_ || $innerType instanceof Iterable_ || $innerType instanceof Mixed_ + || $innerType instanceof Callable_ // Try to match generic phpdoc-provided iterables with non-generic return-type-provided iterables // Example: (return type `\ArrayObject`, phpdoc `\ArrayObject`) || ($innerType instanceof Object_ diff --git a/src/Mappers/Root/CallableTypeMapper.php b/src/Mappers/Root/CallableTypeMapper.php new file mode 100644 index 0000000000..7571d15d0a --- /dev/null +++ b/src/Mappers/Root/CallableTypeMapper.php @@ -0,0 +1,61 @@ +next->toGraphQLOutputType($type, $subType, $reflector, $docBlockObj); + } + + if ($type->getParameters()) { + throw CannotMapTypeException::createForUnexpectedCallableParameters(); + } + + $returnType = $type->getReturnType(); + + if (! $returnType) { + throw CannotMapTypeException::createForMissingCallableReturnType(); + } + + return $this->topRootTypeMapper->toGraphQLOutputType($returnType, null, $reflector, $docBlockObj); + } + + public function toGraphQLInputType(Type $type, InputType|null $subType, string $argumentName, ReflectionMethod|ReflectionProperty $reflector, DocBlock $docBlockObj): InputType&GraphQLType + { + if (! $type instanceof Callable_) { + return $this->next->toGraphQLInputType($type, $subType, $argumentName, $reflector, $docBlockObj); + } + + throw CannotMapTypeException::createForCallableAsInput(); + } + + public function mapNameToType(string $typeName): NamedType&GraphQLType + { + return $this->next->mapNameToType($typeName); + } +} diff --git a/src/QueryField.php b/src/QueryField.php index 5a224b70a9..3027cf3c71 100644 --- a/src/QueryField.php +++ b/src/QueryField.php @@ -5,6 +5,7 @@ namespace TheCodingMachine\GraphQLite; use Closure; +use GraphQL\Deferred; use GraphQL\Error\ClientAware; use GraphQL\Executor\Promise\Adapter\SyncPromise; use GraphQL\Language\AST\FieldDefinitionNode; @@ -20,6 +21,8 @@ use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; use TheCodingMachine\GraphQLite\Parameters\SourceParameter; +use function is_callable; + /** * A GraphQL field that maps to a PHP method automatically. * @@ -92,6 +95,13 @@ public function __construct( private function resolveWithPromise(mixed $result, ResolverInterface $originalResolver): mixed { + // Shorthand for deferring field execution. This does two things: + // - removes the dependency on `GraphQL\Deferred` from user land code + // - allows inferring the type from PHPDoc (callable(): Type), unlike Deferred, which is not generic + if (is_callable($result)) { + $result = new Deferred($result); + } + if ($result instanceof SyncPromise) { return $result->then(fn ($resolvedValue) => $this->resolveWithPromise($resolvedValue, $originalResolver)); } diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index 53b43948b6..92f263d448 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -35,6 +35,7 @@ use TheCodingMachine\GraphQLite\Mappers\PorpaginasTypeMapper; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\BaseTypeMapper; +use TheCodingMachine\GraphQLite\Mappers\Root\CallableTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\CompoundTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\EnumTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\FinalRootTypeMapper; @@ -399,6 +400,7 @@ public function createSchema(): Schema $lastTopRootTypeMapper = new LastDelegatingTypeMapper(); $topRootTypeMapper = new NullableTypeMapperAdapter($lastTopRootTypeMapper); $topRootTypeMapper = new VoidTypeMapper($topRootTypeMapper); + $topRootTypeMapper = new CallableTypeMapper($topRootTypeMapper, $lastTopRootTypeMapper); $errorRootTypeMapper = new FinalRootTypeMapper($recursiveTypeMapper); $rootTypeMapper = new BaseTypeMapper($errorRootTypeMapper, $recursiveTypeMapper, $topRootTypeMapper); diff --git a/tests/AbstractQueryProvider.php b/tests/AbstractQueryProvider.php index 88ae390f47..132616dc8b 100644 --- a/tests/AbstractQueryProvider.php +++ b/tests/AbstractQueryProvider.php @@ -41,6 +41,7 @@ use TheCodingMachine\GraphQLite\Mappers\Parameters\ResolveInfoParameterHandler; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\BaseTypeMapper; +use TheCodingMachine\GraphQLite\Mappers\Root\CallableTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\CompoundTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\EnumTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\FinalRootTypeMapper; @@ -359,6 +360,7 @@ protected function buildRootTypeMapper(): RootTypeMapperInterface $lastTopRootTypeMapper = new LastDelegatingTypeMapper(); $topRootTypeMapper = new NullableTypeMapperAdapter($lastTopRootTypeMapper); $topRootTypeMapper = new VoidTypeMapper($topRootTypeMapper); + $topRootTypeMapper = new CallableTypeMapper($topRootTypeMapper, $lastTopRootTypeMapper); $errorRootTypeMapper = new FinalRootTypeMapper($this->getTypeMapper()); $rootTypeMapper = new BaseTypeMapper( diff --git a/tests/Fixtures/Integration/Models/Blog.php b/tests/Fixtures/Integration/Models/Blog.php index 80812119f2..6ced0ee506 100644 --- a/tests/Fixtures/Integration/Models/Blog.php +++ b/tests/Fixtures/Integration/Models/Blog.php @@ -80,4 +80,10 @@ public static function prefetchSubBlogs(iterable $blogs): array return $subBlogs; } + + /** @return callable(): User */ + #[Field] + public function author(): callable { + return fn () => new User('Author', 'author@graphqlite'); + } } diff --git a/tests/Integration/EndToEndTest.php b/tests/Integration/EndToEndTest.php index 2423be089d..27503359b1 100644 --- a/tests/Integration/EndToEndTest.php +++ b/tests/Integration/EndToEndTest.php @@ -2359,6 +2359,9 @@ public function testPrefetchingOfSameTypeInDifferentNestingLevels(): void } } } + author { + email + } posts { title comments { @@ -2399,6 +2402,9 @@ public function testPrefetchingOfSameTypeInDifferentNestingLevels(): void ], ], ], + 'author' => [ + 'email' => 'author@graphqlite', + ], 'posts' => [ [ 'title' => 'post-1.1', @@ -2435,6 +2441,9 @@ public function testPrefetchingOfSameTypeInDifferentNestingLevels(): void ], ], ], + 'author' => [ + 'email' => 'author@graphqlite', + ], 'posts' => [ [ 'title' => 'post-2.1', diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php index 1b1aa796b9..b6b34e0936 100644 --- a/tests/Integration/IntegrationTestCase.php +++ b/tests/Integration/IntegrationTestCase.php @@ -41,6 +41,7 @@ use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapper; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapperInterface; use TheCodingMachine\GraphQLite\Mappers\Root\BaseTypeMapper; +use TheCodingMachine\GraphQLite\Mappers\Root\CallableTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\CompoundTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\EnumTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\FinalRootTypeMapper; @@ -296,10 +297,13 @@ public function createContainer(array $overloadedServices = []): ContainerInterf ); }, RootTypeMapperInterface::class => static function (ContainerInterface $container) { - return new VoidTypeMapper( - new NullableTypeMapperAdapter( - $container->get('topRootTypeMapper') - ) + return new CallableTypeMapper( + new VoidTypeMapper( + new NullableTypeMapperAdapter( + $container->get('topRootTypeMapper') + ) + ), + $container->get('topRootTypeMapper') ); }, 'topRootTypeMapper' => static function () { diff --git a/tests/Mappers/Root/CallableTypeMapperTest.php b/tests/Mappers/Root/CallableTypeMapperTest.php new file mode 100644 index 0000000000..53d9c9d147 --- /dev/null +++ b/tests/Mappers/Root/CallableTypeMapperTest.php @@ -0,0 +1,131 @@ +createMock(RootTypeMapperInterface::class); + $topRootMapper->expects($this->once()) + ->method('toGraphQLOutputType') + ->with($returnType, null, $reflection, $docBlock) + ->willReturn(GraphQLType::string()); + + $mapper = new CallableTypeMapper( + $this->createMock(RootTypeMapperInterface::class), + $topRootMapper, + ); + + $result = $mapper->toGraphQLOutputType(new Callable_(returnType: $returnType), null, $reflection, $docBlock); + + $this->assertSame(GraphQLType::string(), $result); + } + + public function testThrowsWhenUsingCallableWithParameters(): void + { + $this->expectExceptionObject(CannotMapTypeException::createForUnexpectedCallableParameters()); + + $mapper = new CallableTypeMapper( + $this->createMock(RootTypeMapperInterface::class), + $this->createMock(RootTypeMapperInterface::class) + ); + + $type = new Callable_( + parameters: [ + new CallableParameter(new String_()) + ] + ); + + $mapper->toGraphQLOutputType($type, null, new ReflectionMethod(__CLASS__, 'testSkipsNonCallables'), new DocBlock()); + } + + public function testThrowsWhenUsingCallableWithoutReturnType(): void + { + $this->expectExceptionObject(CannotMapTypeException::createForMissingCallableReturnType()); + + $mapper = new CallableTypeMapper( + $this->createMock(RootTypeMapperInterface::class), + $this->createMock(RootTypeMapperInterface::class) + ); + + $mapper->toGraphQLOutputType(new Callable_(), null, new ReflectionMethod(__CLASS__, 'testSkipsNonCallables'), new DocBlock()); + } + + public function testThrowsWhenUsingCallableAsInputType(): void + { + $this->expectExceptionObject(CannotMapTypeException::createForCallableAsInput()); + + $mapper = new CallableTypeMapper( + $this->createMock(RootTypeMapperInterface::class), + $this->createMock(RootTypeMapperInterface::class) + ); + + $mapper->toGraphQLInputType(new Callable_(), null, 'arg1', new ReflectionMethod(__CLASS__, 'testSkipsNonCallables'), new DocBlock()); + } + + #[DataProvider('skipsNonCallablesProvider')] + public function testSkipsNonCallables(callable $createType): void + { + $type = $createType(); + $reflection = new ReflectionMethod(__CLASS__, 'testSkipsNonCallables'); + $docBlock = new DocBlock(); + + $next = $this->createMock(RootTypeMapperInterface::class); + $next->expects($this->once()) + ->method('toGraphQLOutputType') + ->with($type, null, $reflection, $docBlock) + ->willReturn(GraphQLType::string()); + $next->expects($this->once()) + ->method('toGraphQLInputType') + ->with($type, null, 'arg1', $reflection, $docBlock) + ->willReturn(GraphQLType::int()); + $next->expects($this->once()) + ->method('mapNameToType') + ->with('Name') + ->willReturn(GraphQLType::float()); + + $mapper = new CallableTypeMapper($next, $this->createMock(RootTypeMapperInterface::class)); + + $this->assertSame(GraphQLType::string(), $mapper->toGraphQLOutputType($type, null, $reflection, $docBlock)); + $this->assertSame(GraphQLType::int(), $mapper->toGraphQLInputType($type, null, 'arg1', $reflection, $docBlock)); + $this->assertSame(GraphQLType::float(), $mapper->mapNameToType('Name')); + } + + public static function skipsNonCallablesProvider(): iterable + { + yield [fn () => new Object_()]; + yield [fn () => new Array_()]; + yield [fn () => new String_()]; + } +} diff --git a/tests/QueryFieldTest.php b/tests/QueryFieldTest.php index 38b0f19f3b..a3e05f69f1 100644 --- a/tests/QueryFieldTest.php +++ b/tests/QueryFieldTest.php @@ -5,6 +5,9 @@ namespace TheCodingMachine\GraphQLite; use GraphQL\Error\Error; +use GraphQL\Executor\Promise\Adapter\SyncPromise; +use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter; +use GraphQL\Executor\Promise\Promise; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; use PHPUnit\Framework\TestCase; @@ -45,4 +48,21 @@ public function testParametersDescription(): void $this->assertEquals('Foo argument', $queryField->args[0]->description); } + + public function testWrapsCallableInDeferred(): void + { + $sourceResolver = new ServiceResolver(static fn () => function () { + return 123; + }); + $queryField = new QueryField('foo', Type::string(), [], $sourceResolver, $sourceResolver, null, null, []); + + $deferred = ($queryField->resolveFn)(null, [], null, $this->createStub(ResolveInfo::class)); + + $this->assertInstanceOf(SyncPromise::class, $deferred); + + $syncPromiseAdapter = new SyncPromiseAdapter(); + $syncPromiseAdapter->wait(new Promise($deferred, $syncPromiseAdapter)); + + $this->assertSame(123, $deferred->result); + } } diff --git a/website/docs/type-mapping.mdx b/website/docs/type-mapping.mdx index d5b6c4dc29..89f7acc557 100644 --- a/website/docs/type-mapping.mdx +++ b/website/docs/type-mapping.mdx @@ -332,12 +332,11 @@ query { ## Promise mapping -GraphQL includes a native \GraphQL\Deferred type. -You can map the return type by adding a detailed `@return` statement in the PHPDoc. -An alternative to the `@return` statement is using `#[Field(outputType: SomeGQLType)]`. +You can defer execution of fields by returning a callable. To specify the field type, add a `@return` PHPDoc annotation +with a return type, like so: `@return callable(): YourTypeHere`. The callable must not have any parameters. -All the previously mentioned mappings work with Promises, except when a return type is explicitly declared -in the method signature. +An alternative way is to return `\GraphQL\Deferred` instances, along with specifying the type through the +`outputType` parameter of field attributes: `#[Field(outputType: SomeGQLType)]`. This allows you to use \Overblog\DataLoader\DataLoader as an alternative for resolving N+1 query issues and caching intermediate results. @@ -349,12 +348,12 @@ class Product // ... /** - * @return string + * @return callable(): string */ #[Field] - public function getName(): Deferred + public function getName(): callable { - return new Deferred(fn() => $this->name); + return fn() => $this->name; } #[Field(outputType: "Float")] From 0d50325d9b11af39175847c77fb767efda88c548 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Jun 2025 11:03:30 -0400 Subject: [PATCH 104/108] Bump codecov/codecov-action from 5.4.0 to 5.4.3 (#747) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.4.0 to 5.4.3. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v5.4.0...v5.4.3) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-version: 5.4.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 49638e7e49..42e69232e8 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -85,7 +85,7 @@ jobs: path: "build" overwrite: true - - uses: codecov/codecov-action@v5.4.0 # upload the coverage to codecov + - uses: codecov/codecov-action@v5.4.3 # upload the coverage to codecov with: fail_ci_if_error: false # optional (default = false) - Need CODECOV_TOKEN # Do not upload in forks, and only on php8.4, latest deps From 54034734d962f1d73068dedb71895aa90f7d3f07 Mon Sep 17 00:00:00 2001 From: Andrii Dembitskyi Date: Fri, 6 Jun 2025 17:43:09 +0200 Subject: [PATCH 105/108] :package: Use WeakMap instead of SplObjectStorage to simplify GC work (#745) --- src/Context/Context.php | 8 ++++---- src/PrefetchBuffer.php | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Context/Context.php b/src/Context/Context.php index 9a8ddddcce..ce2fdbca93 100644 --- a/src/Context/Context.php +++ b/src/Context/Context.php @@ -4,20 +4,20 @@ namespace TheCodingMachine\GraphQLite\Context; -use SplObjectStorage; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; use TheCodingMachine\GraphQLite\PrefetchBuffer; +use WeakMap; /** * A context class that should be passed to the Webonyx executor. */ class Context implements ContextInterface, ResetableContextInterface { - private SplObjectStorage $prefetchBuffers; + private WeakMap $prefetchBuffers; public function __construct() { - $this->prefetchBuffers = new SplObjectStorage(); + $this->prefetchBuffers = new WeakMap(); } /** @@ -38,6 +38,6 @@ public function getPrefetchBuffer(ParameterInterface $field): PrefetchBuffer public function reset(): void { - $this->prefetchBuffers = new SplObjectStorage(); + $this->prefetchBuffers = new WeakMap(); } } diff --git a/src/PrefetchBuffer.php b/src/PrefetchBuffer.php index 69ebcb706b..317eb5d920 100644 --- a/src/PrefetchBuffer.php +++ b/src/PrefetchBuffer.php @@ -5,7 +5,7 @@ namespace TheCodingMachine\GraphQLite; use GraphQL\Type\Definition\ResolveInfo; -use SplObjectStorage; +use WeakMap; use function md5; use function serialize; @@ -18,12 +18,12 @@ class PrefetchBuffer /** @var array> An array of buffered, indexed by hash of arguments. */ private array $objects = []; - /** @var SplObjectStorage A Storage of prefetch method results, holds source to resolved values. */ - private SplObjectStorage $results; + /** @var WeakMap A Storage of prefetch method results, holds source to resolved values. */ + private WeakMap $results; public function __construct() { - $this->results = new SplObjectStorage(); + $this->results = new WeakMap(); } /** @param array $arguments The input arguments passed from GraphQL to the field. */ From e33f58766c3ff4cec46aaf7d5829517cf4b2e5bf Mon Sep 17 00:00:00 2001 From: Oleksandr Prypkhan Date: Fri, 1 Aug 2025 03:01:48 +0300 Subject: [PATCH 106/108] Fix callable type mapping to be Closure mapping (#753) * Fix callable type mapping to be Closure mapping * Fix broken callable mapping --- src/Mappers/CannotMapTypeException.php | 17 ++-- src/Mappers/Root/CallableTypeMapper.php | 61 ------------ src/Mappers/Root/ClosureTypeMapper.php | 99 +++++++++++++++++++ src/QueryField.php | 6 +- src/SchemaFactory.php | 4 +- tests/AbstractQueryProvider.php | 4 +- tests/Fixtures/Integration/Models/Blog.php | 5 +- tests/Integration/IntegrationTestCase.php | 4 +- ...pperTest.php => ClosureTypeMapperTest.php} | 67 +++++++++---- tests/QueryFieldTest.php | 2 +- website/docs/type-mapping.mdx | 22 ++++- 11 files changed, 188 insertions(+), 103 deletions(-) delete mode 100644 src/Mappers/Root/CallableTypeMapper.php create mode 100644 src/Mappers/Root/ClosureTypeMapper.php rename tests/Mappers/Root/{CallableTypeMapperTest.php => ClosureTypeMapperTest.php} (69%) diff --git a/src/Mappers/CannotMapTypeException.php b/src/Mappers/CannotMapTypeException.php index aa1331747d..84fba9bce2 100644 --- a/src/Mappers/CannotMapTypeException.php +++ b/src/Mappers/CannotMapTypeException.php @@ -181,18 +181,23 @@ public static function createForNonNullReturnByTypeMapper(): self return new self('a type mapper returned a GraphQL\Type\Definition\NonNull instance. All instances returned by type mappers should be nullable. It is the role of the NullableTypeMapperAdapter class to make a GraphQL type in a "NonNull". Note: this is an error in the TypeMapper code or in GraphQLite itself. Please check your custom type mappers or open an issue on GitHub if you don\'t have any custom type mapper.'); } - public static function createForUnexpectedCallableParameters(): self + public static function createForUnexpectedCallable(): self { - return new self('callable() type-hint must not specify any parameters.'); + return new self('callable() type-hint is not supported. Use Closure: Closure(): int'); } - public static function createForMissingCallableReturnType(): self + public static function createForUnexpectedClosureParameters(): self { - return new self('callable() type-hint must specify its return type. For instance: callable(): int'); + return new self('Closure() type-hint must not specify any parameters.'); } - public static function createForCallableAsInput(): self + public static function createForMissingClosureReturnType(): self { - return new self('callable() type-hint can only be used as output type.'); + return new self('Closure() type-hint must specify its return type. For instance: Closure(): int'); + } + + public static function createForClosureAsInput(): self + { + return new self('Closure() type-hint can only be used as output type.'); } } diff --git a/src/Mappers/Root/CallableTypeMapper.php b/src/Mappers/Root/CallableTypeMapper.php deleted file mode 100644 index 7571d15d0a..0000000000 --- a/src/Mappers/Root/CallableTypeMapper.php +++ /dev/null @@ -1,61 +0,0 @@ -next->toGraphQLOutputType($type, $subType, $reflector, $docBlockObj); - } - - if ($type->getParameters()) { - throw CannotMapTypeException::createForUnexpectedCallableParameters(); - } - - $returnType = $type->getReturnType(); - - if (! $returnType) { - throw CannotMapTypeException::createForMissingCallableReturnType(); - } - - return $this->topRootTypeMapper->toGraphQLOutputType($returnType, null, $reflector, $docBlockObj); - } - - public function toGraphQLInputType(Type $type, InputType|null $subType, string $argumentName, ReflectionMethod|ReflectionProperty $reflector, DocBlock $docBlockObj): InputType&GraphQLType - { - if (! $type instanceof Callable_) { - return $this->next->toGraphQLInputType($type, $subType, $argumentName, $reflector, $docBlockObj); - } - - throw CannotMapTypeException::createForCallableAsInput(); - } - - public function mapNameToType(string $typeName): NamedType&GraphQLType - { - return $this->next->mapNameToType($typeName); - } -} diff --git a/src/Mappers/Root/ClosureTypeMapper.php b/src/Mappers/Root/ClosureTypeMapper.php new file mode 100644 index 0000000000..d57476a30e --- /dev/null +++ b/src/Mappers/Root/ClosureTypeMapper.php @@ -0,0 +1,99 @@ +closureType = new Object_(new Fqsen('\\' . Closure::class)); + } + + public function toGraphQLOutputType(Type $type, OutputType|null $subType, ReflectionMethod|ReflectionProperty $reflector, DocBlock $docBlockObj): OutputType&GraphQLType + { + // This check exists because any string may be a callable (referring to a global function), + // so if a string that looks like a callable is returned from a resolver, it will get wrapped + // in `Deferred`, even though it wasn't supposed to be a deferred value. This could be fixed + // by combining `QueryField`'s resolver and `CallableTypeMapper` into one place, but + // that's not currently possible with GraphQLite's design. + if ($type instanceof Callable_) { + throw CannotMapTypeException::createForUnexpectedCallable(); + } + + if (! $type instanceof Compound || ! $type->contains($this->closureType)) { + return $this->next->toGraphQLOutputType($type, $subType, $reflector, $docBlockObj); + } + + $allTypes = iterator_to_array($type); + + if (count($allTypes) !== 2) { + return $this->next->toGraphQLOutputType($type, $subType, $reflector, $docBlockObj); + } + + $callableType = $this->findCallableType($allTypes); + $returnType = $callableType?->getReturnType(); + + if (! $returnType) { + throw CannotMapTypeException::createForMissingClosureReturnType(); + } + + if ($callableType->getParameters()) { + throw CannotMapTypeException::createForUnexpectedClosureParameters(); + } + + return $this->topRootTypeMapper->toGraphQLOutputType($returnType, null, $reflector, $docBlockObj); + } + + public function toGraphQLInputType(Type $type, InputType|null $subType, string $argumentName, ReflectionMethod|ReflectionProperty $reflector, DocBlock $docBlockObj): InputType&GraphQLType + { + if (! $type instanceof Callable_) { + return $this->next->toGraphQLInputType($type, $subType, $argumentName, $reflector, $docBlockObj); + } + + throw CannotMapTypeException::createForClosureAsInput(); + } + + public function mapNameToType(string $typeName): NamedType&GraphQLType + { + return $this->next->mapNameToType($typeName); + } + + /** @param array $types */ + private function findCallableType(array $types): Callable_|null + { + foreach ($types as $type) { + if ($type instanceof Callable_) { + return $type; + } + } + + return null; + } +} diff --git a/src/QueryField.php b/src/QueryField.php index 3027cf3c71..0d6e011b6c 100644 --- a/src/QueryField.php +++ b/src/QueryField.php @@ -21,8 +21,6 @@ use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; use TheCodingMachine\GraphQLite\Parameters\SourceParameter; -use function is_callable; - /** * A GraphQL field that maps to a PHP method automatically. * @@ -97,8 +95,8 @@ private function resolveWithPromise(mixed $result, ResolverInterface $originalRe { // Shorthand for deferring field execution. This does two things: // - removes the dependency on `GraphQL\Deferred` from user land code - // - allows inferring the type from PHPDoc (callable(): Type), unlike Deferred, which is not generic - if (is_callable($result)) { + // - allows inferring the type from PHPDoc (Closure(): Type), unlike Deferred, which is not generic + if ($result instanceof Closure) { $result = new Deferred($result); } diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index 92f263d448..037910f281 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -35,7 +35,7 @@ use TheCodingMachine\GraphQLite\Mappers\PorpaginasTypeMapper; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\BaseTypeMapper; -use TheCodingMachine\GraphQLite\Mappers\Root\CallableTypeMapper; +use TheCodingMachine\GraphQLite\Mappers\Root\ClosureTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\CompoundTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\EnumTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\FinalRootTypeMapper; @@ -400,7 +400,7 @@ public function createSchema(): Schema $lastTopRootTypeMapper = new LastDelegatingTypeMapper(); $topRootTypeMapper = new NullableTypeMapperAdapter($lastTopRootTypeMapper); $topRootTypeMapper = new VoidTypeMapper($topRootTypeMapper); - $topRootTypeMapper = new CallableTypeMapper($topRootTypeMapper, $lastTopRootTypeMapper); + $topRootTypeMapper = new ClosureTypeMapper($topRootTypeMapper, $lastTopRootTypeMapper); $errorRootTypeMapper = new FinalRootTypeMapper($recursiveTypeMapper); $rootTypeMapper = new BaseTypeMapper($errorRootTypeMapper, $recursiveTypeMapper, $topRootTypeMapper); diff --git a/tests/AbstractQueryProvider.php b/tests/AbstractQueryProvider.php index 132616dc8b..e9a25dc48e 100644 --- a/tests/AbstractQueryProvider.php +++ b/tests/AbstractQueryProvider.php @@ -41,7 +41,7 @@ use TheCodingMachine\GraphQLite\Mappers\Parameters\ResolveInfoParameterHandler; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\BaseTypeMapper; -use TheCodingMachine\GraphQLite\Mappers\Root\CallableTypeMapper; +use TheCodingMachine\GraphQLite\Mappers\Root\ClosureTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\CompoundTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\EnumTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\FinalRootTypeMapper; @@ -360,7 +360,7 @@ protected function buildRootTypeMapper(): RootTypeMapperInterface $lastTopRootTypeMapper = new LastDelegatingTypeMapper(); $topRootTypeMapper = new NullableTypeMapperAdapter($lastTopRootTypeMapper); $topRootTypeMapper = new VoidTypeMapper($topRootTypeMapper); - $topRootTypeMapper = new CallableTypeMapper($topRootTypeMapper, $lastTopRootTypeMapper); + $topRootTypeMapper = new ClosureTypeMapper($topRootTypeMapper, $lastTopRootTypeMapper); $errorRootTypeMapper = new FinalRootTypeMapper($this->getTypeMapper()); $rootTypeMapper = new BaseTypeMapper( diff --git a/tests/Fixtures/Integration/Models/Blog.php b/tests/Fixtures/Integration/Models/Blog.php index 6ced0ee506..f26b02f52e 100644 --- a/tests/Fixtures/Integration/Models/Blog.php +++ b/tests/Fixtures/Integration/Models/Blog.php @@ -4,6 +4,7 @@ namespace TheCodingMachine\GraphQLite\Fixtures\Integration\Models; +use Closure; use GraphQL\Deferred; use TheCodingMachine\GraphQLite\Annotations\Field; use TheCodingMachine\GraphQLite\Annotations\Prefetch; @@ -81,9 +82,9 @@ public static function prefetchSubBlogs(iterable $blogs): array return $subBlogs; } - /** @return callable(): User */ + /** @return Closure(): User */ #[Field] - public function author(): callable { + public function author(): Closure { return fn () => new User('Author', 'author@graphqlite'); } } diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php index b6b34e0936..c3613f26a6 100644 --- a/tests/Integration/IntegrationTestCase.php +++ b/tests/Integration/IntegrationTestCase.php @@ -41,7 +41,7 @@ use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapper; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapperInterface; use TheCodingMachine\GraphQLite\Mappers\Root\BaseTypeMapper; -use TheCodingMachine\GraphQLite\Mappers\Root\CallableTypeMapper; +use TheCodingMachine\GraphQLite\Mappers\Root\ClosureTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\CompoundTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\EnumTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\FinalRootTypeMapper; @@ -297,7 +297,7 @@ public function createContainer(array $overloadedServices = []): ContainerInterf ); }, RootTypeMapperInterface::class => static function (ContainerInterface $container) { - return new CallableTypeMapper( + return new ClosureTypeMapper( new VoidTypeMapper( new NullableTypeMapperAdapter( $container->get('topRootTypeMapper') diff --git a/tests/Mappers/Root/CallableTypeMapperTest.php b/tests/Mappers/Root/ClosureTypeMapperTest.php similarity index 69% rename from tests/Mappers/Root/CallableTypeMapperTest.php rename to tests/Mappers/Root/ClosureTypeMapperTest.php index 53d9c9d147..7053ac5d00 100644 --- a/tests/Mappers/Root/CallableTypeMapperTest.php +++ b/tests/Mappers/Root/ClosureTypeMapperTest.php @@ -2,6 +2,7 @@ namespace TheCodingMachine\GraphQLite\Mappers\Root; +use Closure; use Generator; use GraphQL\Type\Definition\InputType; use GraphQL\Type\Definition\NamedType; @@ -10,10 +11,12 @@ use GraphQL\Type\Definition\StringType; use GraphQL\Type\Definition\Type as GraphQLType; use phpDocumentor\Reflection\DocBlock; +use phpDocumentor\Reflection\Fqsen; use phpDocumentor\Reflection\Type; use phpDocumentor\Reflection\Types\Array_; use phpDocumentor\Reflection\Types\Callable_; use phpDocumentor\Reflection\Types\CallableParameter; +use phpDocumentor\Reflection\Types\Compound; use phpDocumentor\Reflection\Types\Nullable; use phpDocumentor\Reflection\Types\Object_; use phpDocumentor\Reflection\Types\String_; @@ -25,9 +28,9 @@ use TheCodingMachine\GraphQLite\Fixtures\TestObject2; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException; -#[CoversClass(CallableTypeMapper::class)] +#[CoversClass(ClosureTypeMapper::class)] #[CoversClass(CannotMapTypeException::class)] -class CallableTypeMapperTest extends AbstractQueryProvider +class ClosureTypeMapperTest extends AbstractQueryProvider { public function testMapsCallableReturnTypeUsingTopRootMapper(): void { @@ -42,51 +45,77 @@ public function testMapsCallableReturnTypeUsingTopRootMapper(): void ->with($returnType, null, $reflection, $docBlock) ->willReturn(GraphQLType::string()); - $mapper = new CallableTypeMapper( + $mapper = new ClosureTypeMapper( $this->createMock(RootTypeMapperInterface::class), $topRootMapper, ); - $result = $mapper->toGraphQLOutputType(new Callable_(returnType: $returnType), null, $reflection, $docBlock); + $type = new Compound([ + new Callable_(returnType: $returnType), + new Object_(new Fqsen('\\' . Closure::class)) + ]); + + $result = $mapper->toGraphQLOutputType($type, null, $reflection, $docBlock); $this->assertSame(GraphQLType::string(), $result); } - public function testThrowsWhenUsingCallableWithParameters(): void + public function testThrowsWhenUsingCallable(): void { - $this->expectExceptionObject(CannotMapTypeException::createForUnexpectedCallableParameters()); + $this->expectExceptionObject(CannotMapTypeException::createForUnexpectedCallable()); - $mapper = new CallableTypeMapper( + $mapper = new ClosureTypeMapper( $this->createMock(RootTypeMapperInterface::class), $this->createMock(RootTypeMapperInterface::class) ); - $type = new Callable_( - parameters: [ - new CallableParameter(new String_()) - ] + $mapper->toGraphQLOutputType(new Callable_(), null, new ReflectionMethod(__CLASS__, 'testSkipsNonCallables'), new DocBlock()); + } + + public function testThrowsWhenUsingClosureWithParameters(): void + { + $this->expectExceptionObject(CannotMapTypeException::createForUnexpectedClosureParameters()); + + $mapper = new ClosureTypeMapper( + $this->createMock(RootTypeMapperInterface::class), + $this->createMock(RootTypeMapperInterface::class) ); + $type = new Compound([ + new Callable_( + parameters: [ + new CallableParameter(new String_()) + ], + returnType: new String_() + ), + new Object_(new Fqsen('\\' . Closure::class)) + ]); + $mapper->toGraphQLOutputType($type, null, new ReflectionMethod(__CLASS__, 'testSkipsNonCallables'), new DocBlock()); } - public function testThrowsWhenUsingCallableWithoutReturnType(): void + public function testThrowsWhenUsingClosureWithoutReturnType(): void { - $this->expectExceptionObject(CannotMapTypeException::createForMissingCallableReturnType()); + $this->expectExceptionObject(CannotMapTypeException::createForMissingClosureReturnType()); - $mapper = new CallableTypeMapper( + $mapper = new ClosureTypeMapper( $this->createMock(RootTypeMapperInterface::class), $this->createMock(RootTypeMapperInterface::class) ); - $mapper->toGraphQLOutputType(new Callable_(), null, new ReflectionMethod(__CLASS__, 'testSkipsNonCallables'), new DocBlock()); + $type = new Compound([ + new Callable_(), + new Object_(new Fqsen('\\' . Closure::class)) + ]); + + $mapper->toGraphQLOutputType($type, null, new ReflectionMethod(__CLASS__, 'testSkipsNonCallables'), new DocBlock()); } - public function testThrowsWhenUsingCallableAsInputType(): void + public function testThrowsWhenUsingClosureAsInputType(): void { - $this->expectExceptionObject(CannotMapTypeException::createForCallableAsInput()); + $this->expectExceptionObject(CannotMapTypeException::createForClosureAsInput()); - $mapper = new CallableTypeMapper( + $mapper = new ClosureTypeMapper( $this->createMock(RootTypeMapperInterface::class), $this->createMock(RootTypeMapperInterface::class) ); @@ -115,7 +144,7 @@ public function testSkipsNonCallables(callable $createType): void ->with('Name') ->willReturn(GraphQLType::float()); - $mapper = new CallableTypeMapper($next, $this->createMock(RootTypeMapperInterface::class)); + $mapper = new ClosureTypeMapper($next, $this->createMock(RootTypeMapperInterface::class)); $this->assertSame(GraphQLType::string(), $mapper->toGraphQLOutputType($type, null, $reflection, $docBlock)); $this->assertSame(GraphQLType::int(), $mapper->toGraphQLInputType($type, null, 'arg1', $reflection, $docBlock)); diff --git a/tests/QueryFieldTest.php b/tests/QueryFieldTest.php index a3e05f69f1..5ca92d5210 100644 --- a/tests/QueryFieldTest.php +++ b/tests/QueryFieldTest.php @@ -49,7 +49,7 @@ public function testParametersDescription(): void $this->assertEquals('Foo argument', $queryField->args[0]->description); } - public function testWrapsCallableInDeferred(): void + public function testWrapsClosureInDeferred(): void { $sourceResolver = new ServiceResolver(static fn () => function () { return 123; diff --git a/website/docs/type-mapping.mdx b/website/docs/type-mapping.mdx index 89f7acc557..404ea28c24 100644 --- a/website/docs/type-mapping.mdx +++ b/website/docs/type-mapping.mdx @@ -332,8 +332,22 @@ query { ## Promise mapping -You can defer execution of fields by returning a callable. To specify the field type, add a `@return` PHPDoc annotation -with a return type, like so: `@return callable(): YourTypeHere`. The callable must not have any parameters. +You can defer execution of fields by returning a `Closure`. To specify the field type, add a `@return` PHPDoc annotation +with a return type, like so: `@return Closure(): YourTypeHere`. The closure must not have any parameters. + +:::caution + +Only `Closure` type is supported, which means that all of the following will work: +- arrow functions: `fn () => 123` +- anonymous functions: `function () { return 123; }` +- first-class callables: `random_int(...)`, `Integer::random(...)` etc + +But other callables **will not**: +- callable strings: `'random_int'`, `'Integer::random'` +- callable arrays: `[Integer::class, 'random']`, `[$object, 'method']` +- invokable objects: `new class { function __invoke() { return 123; } }` + +::: An alternative way is to return `\GraphQL\Deferred` instances, along with specifying the type through the `outputType` parameter of field attributes: `#[Field(outputType: SomeGQLType)]`. @@ -348,10 +362,10 @@ class Product // ... /** - * @return callable(): string + * @return Closure(): string */ #[Field] - public function getName(): callable + public function getName(): Closure { return fn() => $this->name; } From d87de70f09008dca0d41d4ed9fb282a40e3e851f Mon Sep 17 00:00:00 2001 From: Janusz Mocek Date: Fri, 1 Aug 2025 20:23:23 +0200 Subject: [PATCH 107/108] don't purge the results from buffer (#752) --- src/Parameters/PrefetchDataParameter.php | 5 +-- .../Parameters/PrefetchDataParameterTest.php | 39 +------------------ 2 files changed, 2 insertions(+), 42 deletions(-) diff --git a/src/Parameters/PrefetchDataParameter.php b/src/Parameters/PrefetchDataParameter.php index 9aaab6c7a8..774f3a0bff 100644 --- a/src/Parameters/PrefetchDataParameter.php +++ b/src/Parameters/PrefetchDataParameter.php @@ -57,10 +57,7 @@ public function resolve(object|null $source, array $args, mixed $context, Resolv $this->processPrefetch($args, $context, $info, $prefetchBuffer); } - $result = $prefetchBuffer->getResult($source); - // clear internal storage - $prefetchBuffer->purgeResult($source); - return $result; + return $prefetchBuffer->getResult($source); }); } diff --git a/tests/Parameters/PrefetchDataParameterTest.php b/tests/Parameters/PrefetchDataParameterTest.php index a7e875c76a..b478880cbe 100644 --- a/tests/Parameters/PrefetchDataParameterTest.php +++ b/tests/Parameters/PrefetchDataParameterTest.php @@ -37,47 +37,10 @@ public function testResolveWithExistingResult(): void self::assertSame($prefetchResult, $this->deferredValue($resolvedParameterPromise)); } - public function testResolveWithoutExistingResult(): void - { - $prefetchResult = new stdClass(); - $source = new stdClass(); - $prefetchHandler = function (array $sources, string $second) use ($prefetchResult, $source) { - self::assertSame([$source], $sources); - self::assertSame('rty', $second); - - return $prefetchResult; - }; - - $parameter = new PrefetchDataParameter('field', $prefetchHandler, [ - new InputTypeParameter( - name: 'second', - type: Type::string(), - description: '', - hasDefaultValue: false, - defaultValue: null, - argumentResolver: new ArgumentResolver() - ) - ]); - - $context = new Context(); - $args = [ - 'first' => 'qwe', - 'second' => 'rty', - ]; - $buffer = $context->getPrefetchBuffer($parameter); - - $resolvedParameterPromise = $parameter->resolve($source, $args, $context, $this->createStub(ResolveInfo::class)); - - self::assertFalse($buffer->hasResult($source)); - self::assertSame([$source], $buffer->getObjectsByArguments($args)); - self::assertSame($prefetchResult, $this->deferredValue($resolvedParameterPromise)); - self::assertFalse($buffer->hasResult($source)); - } - private function deferredValue(Deferred $promise): mixed { $syncPromiseAdapter = new SyncPromiseAdapter(); return $syncPromiseAdapter->wait(new Promise($promise, $syncPromiseAdapter)); } -} \ No newline at end of file +} From 6932ed716a57a98b3fa2badbeaf40c52680d1ddb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Aug 2025 03:52:21 -0400 Subject: [PATCH 108/108] Bump actions/checkout from 4 to 5 (#754) Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/continuous_integration.yml | 4 ++-- .github/workflows/doc_generation.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 42e69232e8..29ec6ab9c0 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -29,7 +29,7 @@ jobs: access_token: ${{ github.token }} - name: "Checkout" - uses: "actions/checkout@v4" + uses: "actions/checkout@v5" - name: "Install PHP with extensions" uses: "shivammathur/setup-php@v2" @@ -100,7 +100,7 @@ jobs: fail-fast: false steps: - name: "Checkout" - uses: "actions/checkout@v4" + uses: "actions/checkout@v5" - name: "Install PHP with extensions" uses: "shivammathur/setup-php@v2" with: diff --git a/.github/workflows/doc_generation.yml b/.github/workflows/doc_generation.yml index aeeb34dba5..97caa41546 100644 --- a/.github/workflows/doc_generation.yml +++ b/.github/workflows/doc_generation.yml @@ -19,7 +19,7 @@ jobs: steps: - name: "Checkout" - uses: "actions/checkout@v4" + uses: "actions/checkout@v5" - name: "Setup NodeJS" uses: actions/setup-node@v4