diff --git a/src/FieldsBuilder.php b/src/FieldsBuilder.php index c0dc7d27d1..d33e2ead61 100644 --- a/src/FieldsBuilder.php +++ b/src/FieldsBuilder.php @@ -1140,7 +1140,7 @@ private function getInputFieldsByPropertyAnnotations( $name = $annotation->getName() ?: $refProperty->getName(); $inputType = $annotation->getInputType(); $constructerParameters = $this->getClassConstructParameterNames($refClass); - $inputProperty = $this->typeMapper->mapInputProperty($refProperty, $docBlock, $name, $inputType, $defaultProperties[$refProperty->getName()] ?? null, $isUpdate ? true : null); + $inputProperty = $this->typeMapper->mapInputProperty($refProperty, $docBlock, $name, $inputType, $defaultProperties[$refProperty->getName()] ?? null, $isUpdate ? true : null, isset($defaultProperties[$refProperty->getName()])); if (! $description) { $description = $inputProperty->getDescription(); diff --git a/src/Mappers/Parameters/ResolveInfoParameterHandler.php b/src/Mappers/Parameters/ResolveInfoParameterHandler.php index 33e688cf77..dad3d5b859 100644 --- a/src/Mappers/Parameters/ResolveInfoParameterHandler.php +++ b/src/Mappers/Parameters/ResolveInfoParameterHandler.php @@ -13,15 +13,13 @@ use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; use TheCodingMachine\GraphQLite\Parameters\ResolveInfoParameter; -use function assert; - class ResolveInfoParameterHandler implements ParameterMiddlewareInterface { public function mapParameter(ReflectionParameter $parameter, DocBlock $docBlock, Type|null $paramTagType, ParameterAnnotations $parameterAnnotations, ParameterHandlerInterface $parameterMapper): ParameterInterface { $type = $parameter->getType(); - assert($type === null || $type instanceof ReflectionNamedType); - if ($type !== null && $type->getName() === ResolveInfo::class) { + + if ($type instanceof ReflectionNamedType && $type->getName() === ResolveInfo::class) { return new ResolveInfoParameter(); } diff --git a/src/Mappers/Parameters/TypeHandler.php b/src/Mappers/Parameters/TypeHandler.php index 77fdad3f55..c317de3953 100644 --- a/src/Mappers/Parameters/TypeHandler.php +++ b/src/Mappers/Parameters/TypeHandler.php @@ -39,6 +39,7 @@ use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeExceptionInterface; use TheCodingMachine\GraphQLite\Mappers\Root\RootTypeMapperInterface; +use TheCodingMachine\GraphQLite\Mappers\Root\UndefinedTypeMapper; use TheCodingMachine\GraphQLite\Parameters\DefaultValueParameter; use TheCodingMachine\GraphQLite\Parameters\InputTypeParameter; use TheCodingMachine\GraphQLite\Parameters\InputTypeProperty; @@ -46,6 +47,7 @@ use TheCodingMachine\GraphQLite\Reflection\DocBlock\DocBlockFactory; use TheCodingMachine\GraphQLite\Types\ArgumentResolver; use TheCodingMachine\GraphQLite\Types\TypeResolver; +use TheCodingMachine\GraphQLite\Undefined; use function array_map; use function array_unique; @@ -177,6 +179,19 @@ public function mapParameter( return new DefaultValueParameter($parameter->getDefaultValue()); } + $parameterType = $parameter->getType(); + $allowsNull = $parameterType === null || $parameterType->allowsNull(); + + if ($parameterType === null) { + $phpdocType = new Mixed_(); + $allowsNull = false; + //throw MissingTypeHintException::missingTypeHint($parameter); + } else { + $declaringClass = $parameter->getDeclaringClass(); + assert($declaringClass !== null); + $phpdocType = $this->reflectionTypeToPhpDocType($parameterType, $declaringClass); + } + $useInputType = $parameterAnnotations->getAnnotationByType(UseInputType::class); if ($useInputType !== null) { try { @@ -186,19 +201,6 @@ public function mapParameter( throw $e; } } else { - $parameterType = $parameter->getType(); - $allowsNull = $parameterType === null || $parameterType->allowsNull(); - - if ($parameterType === null) { - $phpdocType = new Mixed_(); - $allowsNull = false; - //throw MissingTypeHintException::missingTypeHint($parameter); - } else { - $declaringClass = $parameter->getDeclaringClass(); - assert($declaringClass !== null); - $phpdocType = $this->reflectionTypeToPhpDocType($parameterType, $declaringClass); - } - try { $declaringFunction = $parameter->getDeclaringFunction(); if (! $declaringFunction instanceof ReflectionMethod) { @@ -220,24 +222,33 @@ public function mapParameter( } } - $description = $this->getParameterDescriptionFromDocBlock($docBlock, $parameter); - $hasDefaultValue = false; $defaultValue = null; - if ($parameter->allowsNull()) { - $hasDefaultValue = true; - } + if ($parameter->isDefaultValueAvailable()) { $hasDefaultValue = true; $defaultValue = $parameter->getDefaultValue(); } + if (! $hasDefaultValue && UndefinedTypeMapper::containsUndefined($phpdocType)) { + $hasDefaultValue = true; + $defaultValue = Undefined::VALUE; + } + + if (! $hasDefaultValue && $parameter->allowsNull()) { + $hasDefaultValue = true; + $defaultValue = null; + } + + $description = $this->getParameterDescriptionFromDocBlock($docBlock, $parameter); + return new InputTypeParameter( name: $parameter->getName(), type: $type, description: $description, hasDefaultValue: $hasDefaultValue, defaultValue: $defaultValue, + defaultValueImplicit: $defaultValue === Undefined::VALUE, argumentResolver: $this->argumentResolver, ); } @@ -307,6 +318,7 @@ public function mapInputProperty( string|null $inputTypeName = null, mixed $defaultValue = null, bool|null $isNullable = null, + bool $hasDefaultValue = false, ): InputTypeProperty { $docBlockComment = $docBlock->getSummary() . PHP_EOL . $docBlock->getDescription()->render(); @@ -329,6 +341,13 @@ public function mapInputProperty( $isNullable = $refProperty->getType()?->allowsNull() ?? false; } + $propertyType = $refProperty->getType(); + if ($propertyType !== null) { + $phpdocType = $this->reflectionTypeToPhpDocType($propertyType, $refProperty->getDeclaringClass()); + } else { + $phpdocType = new Mixed_(); + } + if ($inputTypeName) { $inputType = $this->typeResolver->mapNameToInputType($inputTypeName); } else { @@ -336,7 +355,16 @@ public function mapInputProperty( assert($inputType instanceof InputType); } - $hasDefault = $defaultValue !== null || $isNullable; + if (! $hasDefaultValue && $isNullable) { + $hasDefaultValue = true; + $defaultValue = null; + } + + if (! $hasDefaultValue && UndefinedTypeMapper::containsUndefined($phpdocType)) { + $hasDefaultValue = true; + $defaultValue = Undefined::VALUE; + } + $fieldName = $argumentName ?? $refProperty->getName(); return new InputTypeProperty( @@ -344,8 +372,9 @@ public function mapInputProperty( fieldName: $fieldName, type: $inputType, description: trim($docBlockComment), - hasDefaultValue: $hasDefault, + hasDefaultValue: $hasDefaultValue, defaultValue: $defaultValue, + defaultValueImplicit: $defaultValue === Undefined::VALUE, argumentResolver: $this->argumentResolver, ); } diff --git a/src/Mappers/Root/UndefinedTypeMapper.php b/src/Mappers/Root/UndefinedTypeMapper.php new file mode 100644 index 0000000000..ae91db3e0d --- /dev/null +++ b/src/Mappers/Root/UndefinedTypeMapper.php @@ -0,0 +1,79 @@ +next->toGraphQLOutputType($type, $subType, $reflector, $docBlockObj); + } + + public function toGraphQLInputType(Type $type, InputType|null $subType, string $argumentName, ReflectionMethod|ReflectionProperty $reflector, DocBlock $docBlockObj): InputType&GraphQLType + { + $type = self::replaceUndefinedWith($type); + + return $this->next->toGraphQLInputType($type, $subType, $argumentName, $reflector, $docBlockObj); + } + + public function mapNameToType(string $typeName): NamedType&GraphQLType + { + return $this->next->mapNameToType($typeName); + } + + /** + * Replaces types like this: `int|Undefined` to `int|null` + */ + public static function replaceUndefinedWith(Type $type, Type $replaceWith = new Null_()): Type + { + if ($type instanceof Object_ && ltrim((string) $type->getFqsen(), '\\') === Undefined::class) { + return $replaceWith; + } + + if ($type instanceof Nullable) { + return new Nullable(self::replaceUndefinedWith($type->getActualType(), $replaceWith)); + } + + if ($type instanceof Compound) { + $types = array_map(static fn (Type $type) => self::replaceUndefinedWith($type, $replaceWith), iterator_to_array($type)); + + return new Compound(array_values($types)); + } + + return $type; + } + + public static function containsUndefined(Type $type): bool + { + return (string) $type !== (string) self::replaceUndefinedWith($type); + } +} diff --git a/src/Parameters/InputTypeParameter.php b/src/Parameters/InputTypeParameter.php index 85fd871855..e828f9ac3d 100644 --- a/src/Parameters/InputTypeParameter.php +++ b/src/Parameters/InputTypeParameter.php @@ -10,6 +10,8 @@ use TheCodingMachine\GraphQLite\Types\ArgumentResolver; use TheCodingMachine\GraphQLite\Types\ResolvableMutableInputObjectType; +use function array_key_exists; + class InputTypeParameter implements InputTypeParameterInterface { public function __construct( @@ -18,6 +20,7 @@ public function __construct( private readonly string|null $description, private readonly bool $hasDefaultValue, private readonly mixed $defaultValue, + private readonly bool $defaultValueImplicit, private readonly ArgumentResolver $argumentResolver, ) { @@ -26,7 +29,7 @@ public function __construct( /** @param array $args */ public function resolve(object|null $source, array $args, mixed $context, ResolveInfo $info): mixed { - if (isset($args[$this->name])) { + if (array_key_exists($this->name, $args)) { return $this->argumentResolver->resolve($source, $args[$this->name], $context, $info, $this->type); } @@ -55,12 +58,18 @@ public function getType(): InputType&Type public function hasDefaultValue(): bool { - return $this->hasDefaultValue; + // Unfortunately, we can't treat Undefined as a regular kind of default value. In this context, + // $defaultValue refers to the default value on GraphQL level - e.g. the value that's printed + // into the schema, returned in introspection and substituted by webonyx/graphql when a GraphQL + // query is executed. Unlike regular defaults, this one shouldn't be treated as such - + // because GraphQL itself doesn't have a concept of undefined values, at least not on Schema level. + // It would fail to serialize during printing/introspection. + return $this->hasDefaultValue && ! $this->defaultValueImplicit; } public function getDefaultValue(): mixed { - return $this->defaultValue; + return ! $this->defaultValueImplicit ? $this->defaultValue : null; } public function getDescription(): string diff --git a/src/Parameters/InputTypeProperty.php b/src/Parameters/InputTypeProperty.php index 113059500b..76226d1b49 100644 --- a/src/Parameters/InputTypeProperty.php +++ b/src/Parameters/InputTypeProperty.php @@ -17,6 +17,7 @@ public function __construct( string $description, bool $hasDefaultValue, mixed $defaultValue, + bool $defaultValueImplicit, ArgumentResolver $argumentResolver, ) { @@ -26,6 +27,7 @@ public function __construct( $description, $hasDefaultValue, $defaultValue, + $defaultValueImplicit, $argumentResolver, ); } diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index 037910f281..e3e05e9b38 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -45,6 +45,7 @@ use TheCodingMachine\GraphQLite\Mappers\Root\NullableTypeMapperAdapter; use TheCodingMachine\GraphQLite\Mappers\Root\RootTypeMapperFactoryContext; use TheCodingMachine\GraphQLite\Mappers\Root\RootTypeMapperFactoryInterface; +use TheCodingMachine\GraphQLite\Mappers\Root\UndefinedTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\VoidTypeMapper; use TheCodingMachine\GraphQLite\Mappers\TypeMapperFactoryInterface; use TheCodingMachine\GraphQLite\Mappers\TypeMapperInterface; @@ -399,6 +400,7 @@ public function createSchema(): Schema $lastTopRootTypeMapper = new LastDelegatingTypeMapper(); $topRootTypeMapper = new NullableTypeMapperAdapter($lastTopRootTypeMapper); + $topRootTypeMapper = new UndefinedTypeMapper($topRootTypeMapper); $topRootTypeMapper = new VoidTypeMapper($topRootTypeMapper); $topRootTypeMapper = new ClosureTypeMapper($topRootTypeMapper, $lastTopRootTypeMapper); diff --git a/src/Types/ArgumentResolver.php b/src/Types/ArgumentResolver.php index 7f5f5fb53a..5091741f2a 100644 --- a/src/Types/ArgumentResolver.php +++ b/src/Types/ArgumentResolver.php @@ -32,7 +32,12 @@ class ArgumentResolver */ public function resolve(object|null $source, mixed $val, mixed $context, ResolveInfo $resolveInfo, InputType&Type $type): mixed { + if ($val === null && ! $type instanceof NonNull) { + return null; + } + $type = $this->stripNonNullType($type); + if ($type instanceof ListOfType) { if (! is_array($val)) { throw new InvalidArgumentException('Expected GraphQL List but value passed is not an array.'); diff --git a/src/Undefined.php b/src/Undefined.php new file mode 100644 index 0000000000..5fb23aa0c6 --- /dev/null +++ b/src/Undefined.php @@ -0,0 +1,14 @@ +magazine = $input->magazine; + $article->magazine = 'The New Yorker'; + $article->summary = $input->summary; + if ($input->magazine !== Undefined::VALUE) { + $article->magazine = $input->magazine; + } + return $article; } } diff --git a/tests/Fixtures/Integration/Models/Article.php b/tests/Fixtures/Integration/Models/Article.php index 49d914bc0e..8a2415b269 100644 --- a/tests/Fixtures/Integration/Models/Article.php +++ b/tests/Fixtures/Integration/Models/Article.php @@ -5,6 +5,7 @@ use TheCodingMachine\GraphQLite\Annotations\Field; use TheCodingMachine\GraphQLite\Annotations\Input; use TheCodingMachine\GraphQLite\Annotations\Type; +use TheCodingMachine\GraphQLite\Undefined; #[Type, Input] @@ -17,4 +18,14 @@ class Article extends Post #[Field] public ?string $magazine = null; + + #[Field(for: 'Article')] + public function localizedTitle(string|null|Undefined $locale): string + { + if ($locale === Undefined::VALUE) { + $locale = 'en_US'; + } + + return $locale ? "Localized ($locale): $this->title" : $this->title; + } } diff --git a/tests/Fixtures/Integration/Models/UpdateArticleInput.php b/tests/Fixtures/Integration/Models/UpdateArticleInput.php index cfa2738152..d546fdec22 100644 --- a/tests/Fixtures/Integration/Models/UpdateArticleInput.php +++ b/tests/Fixtures/Integration/Models/UpdateArticleInput.php @@ -5,6 +5,7 @@ use TheCodingMachine\GraphQLite\Annotations\Field; use TheCodingMachine\GraphQLite\Annotations\Input; use TheCodingMachine\GraphQLite\Annotations\Security; +use TheCodingMachine\GraphQLite\Undefined; #[Input] class UpdateArticleInput @@ -12,7 +13,8 @@ class UpdateArticleInput public function __construct( #[Field] #[Security("magazine != 'NYTimes'")] - public readonly string|null $magazine, + public readonly string|null|Undefined $magazine = Undefined::VALUE, + #[Field] public readonly string $summary = 'default', ) { diff --git a/tests/Integration/EndToEndTest.php b/tests/Integration/EndToEndTest.php index 27503359b1..2533142c22 100644 --- a/tests/Integration/EndToEndTest.php +++ b/tests/Integration/EndToEndTest.php @@ -9,6 +9,7 @@ use GraphQL\Server\Helper; use GraphQL\Server\OperationParams; use GraphQL\Server\ServerConfig; +use GraphQL\Utils\SchemaPrinter; use Psr\Container\ContainerInterface; use stdClass; use Symfony\Component\Cache\Adapter\ArrayAdapter; @@ -1882,6 +1883,90 @@ public function testEndToEndInputConstructor(): void $this->assertSame('Access denied.', $result->toArray(DebugFlag::RETHROW_UNSAFE_EXCEPTIONS)['errors'][0]['message']); } + public function testEndToEndInputUndefinedValue(): void + { + $schema = $this->mainContainer->get(Schema::class); + assert($schema instanceof Schema); + + $queryString = ' + mutation { + updateArticle(input: { + summary: "Magazine not provided" + }) { + magazine + summary + localizedTitle + } + } + '; + + $result = GraphQL::executeQuery( + $schema, + $queryString, + ); + + $data = $this->getSuccessResult($result); + $this->assertSame('The New Yorker', $data['updateArticle']['magazine']); + $this->assertSame('Magazine not provided', $data['updateArticle']['summary']); + $this->assertSame('Localized (en_US): test', $data['updateArticle']['localizedTitle']); + + $queryString = ' + mutation { + updateArticle(input: { + magazine: "Only magazine provided" + }) { + magazine + summary + localizedTitle(locale: "es_ES") + } + } + '; + + $result = GraphQL::executeQuery( + $schema, + $queryString, + ); + + $data = $this->getSuccessResult($result); + $this->assertSame('Only magazine provided', $data['updateArticle']['magazine']); + $this->assertSame('default', $data['updateArticle']['summary']); + $this->assertSame('Localized (es_ES): test', $data['updateArticle']['localizedTitle']); + + $queryString = ' + mutation { + updateArticle(input: { + magazine: null, + }) { + magazine + summary + localizedTitle(locale: null) + } + } + '; + + $result = GraphQL::executeQuery( + $schema, + $queryString, + ); + + $data = $this->getSuccessResult($result); + $this->assertNull($data['updateArticle']['magazine']); + $this->assertSame('default', $data['updateArticle']['summary']); + $this->assertSame('test', $data['updateArticle']['localizedTitle']); + } + + public function testEndToEndSchemaIsPrintable(): void + { + $this->expectNotToPerformAssertions(); + + $schema = $this->mainContainer->get(Schema::class); + assert($schema instanceof Schema); + + // This may fail if, for example, a default value is not serializable. This is the case with + // values like Undefined::VALUE, for example, so this test guards us from such cases. + SchemaPrinter::doPrint($schema); + } + public function testEndToEndSetterWithSecurity(): void { $container = $this->createContainer([ diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php index c3613f26a6..8b0d43a4fb 100644 --- a/tests/Integration/IntegrationTestCase.php +++ b/tests/Integration/IntegrationTestCase.php @@ -50,6 +50,7 @@ use TheCodingMachine\GraphQLite\Mappers\Root\MyCLabsEnumTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\NullableTypeMapperAdapter; use TheCodingMachine\GraphQLite\Mappers\Root\RootTypeMapperInterface; +use TheCodingMachine\GraphQLite\Mappers\Root\UndefinedTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\VoidTypeMapper; use TheCodingMachine\GraphQLite\Mappers\TypeMapperInterface; use TheCodingMachine\GraphQLite\Middlewares\AuthorizationFieldMiddleware; @@ -299,8 +300,10 @@ public function createContainer(array $overloadedServices = []): ContainerInterf RootTypeMapperInterface::class => static function (ContainerInterface $container) { return new ClosureTypeMapper( new VoidTypeMapper( - new NullableTypeMapperAdapter( - $container->get('topRootTypeMapper') + new UndefinedTypeMapper( + new NullableTypeMapperAdapter( + $container->get('topRootTypeMapper') + ) ) ), $container->get('topRootTypeMapper') diff --git a/tests/QueryFieldTest.php b/tests/QueryFieldTest.php index 5ca92d5210..513fdd9d3e 100644 --- a/tests/QueryFieldTest.php +++ b/tests/QueryFieldTest.php @@ -43,7 +43,7 @@ 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()), + 'arg' => new InputTypeParameter('arg', Type::string(), 'Foo argument', false, null, false, new ArgumentResolver()), ], $sourceResolver, $sourceResolver, null, null, []); $this->assertEquals('Foo argument', $queryField->args[0]->description); diff --git a/website/docs/input-types.mdx b/website/docs/input-types.mdx index 9b5be40278..3ccbff63cf 100644 --- a/website/docs/input-types.mdx +++ b/website/docs/input-types.mdx @@ -157,6 +157,56 @@ In example above if you use the class as `UpdateUserInput` and set only `usernam 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. +### Optional input fields + +In some cases, you might need to know whether a property was explicitly provided in a GraphQL request or simply omitted. +This is important when performing **partial updates** — e.g., updating only some fields without resetting others to `null`. + +To support this behavior, you can use a special enum called `Undefined`, which acts as a **marker type** distinguishing between: + +- `Undefined::VALUE` → field was **not provided** in the input +- `null` → field was **explicitly set to `null`** +- any other value → field was **explicitly provided** + +Consider this example: + +```php +#[Input] +class UpdateUserInput +{ + #[Field] + public string $username; + + #[Field] + protected int|null|Undefined $age; +} +``` + +Then, when processing your input: + +```php +// Update the age, even if `$input->age` is `null`. But don't update if not provided at all +if ($input->age !== Undefined::VALUE) { + $user->age = $input->age; +} +``` + +Although this is less useful, parameters are also supported: + +```php +#[Type] +class Article +{ + #[Field] + public function title(string|null|Undefined $locale): string + { + if ($locale === Undefined::VALUE) { + return + } + } +} +``` + ## Factory A **Factory** is a method that takes in parameter all the fields of the input type and return an object.