From 666990802ed99039f1bc4e94a6a5bd17ca749435 Mon Sep 17 00:00:00 2001 From: Kirill Sukhorukov Date: Tue, 28 Apr 2026 10:19:27 +0300 Subject: [PATCH] Preserve omitted input fields in DTO hydration --- docs/attributes/arguments-transformer.md | 46 +++++++++++++ src/Definition/Omittable.php | 60 +++++++++++++++++ src/Transformer/ArgumentsTransformer.php | 30 ++++++++- .../Transformer/ArgumentsTransformerTest.php | 66 ++++++++++++++++++- tests/Transformer/InputTypeWithOmittable.php | 27 ++++++++ 5 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 src/Definition/Omittable.php create mode 100644 tests/Transformer/InputTypeWithOmittable.php diff --git a/docs/attributes/arguments-transformer.md b/docs/attributes/arguments-transformer.md index 5b7e2152c..9fd85750e 100644 --- a/docs/attributes/arguments-transformer.md +++ b/docs/attributes/arguments-transformer.md @@ -73,3 +73,49 @@ class RootMutation { ``` So, the resolver (the `createUser` method) will receive an instance of the class `UserRegisterInput` instead of an array of data. + +## Preserving omitted input fields + +For partial update mutations, nullable input fields often need to distinguish between a field that was omitted and a field that was explicitly set to `null`. + +By default, hydrated DTO properties keep the historical behavior: omitted nullable fields and explicit `null` values are both represented as `null`. To opt in to preserving this distinction for one field, type the DTO property as `Omittable`. + +```php +namespace App\GraphQL\Input; + +use Overblog\GraphQLBundle\Annotation as GQL; +use Overblog\GraphQLBundle\Definition\Omittable; + +#[GQL\Input] +class UpdateUserInput { + /** + * @var Omittable + */ + #[GQL\Field(type: "String")] + public Omittable $phone; +} +``` + +The GraphQL schema field is still a regular nullable `String`. Only the hydrated PHP property changes: + +```php +if ($input->phone->isSet()) { + // The client provided phone, either as null or as a string. + $user->setPhone($input->phone->value()); +} +``` + +The possible states are: + +```php +// phone was omitted +$input->phone->isSet() === false; + +// phone was explicitly set to null +$input->phone->isSet() === true; +$input->phone->value() === null; + +// phone was provided with a value +$input->phone->isSet() === true; +$input->phone->value() === '+123'; +``` diff --git a/src/Definition/Omittable.php b/src/Definition/Omittable.php new file mode 100644 index 000000000..e19516c95 --- /dev/null +++ b/src/Definition/Omittable.php @@ -0,0 +1,60 @@ + + */ + public static function omitted(): self + { + /** @var self $omitted */ + $omitted = new self(false); + + return $omitted; + } + + /** + * @param T $value + * + * @return self + */ + public static function set(mixed $value): self + { + return new self(true, $value); + } + + public function isSet(): bool + { + return $this->isSet; + } + + /** + * @return T + */ + public function value(): mixed + { + if (!$this->isSet) { + throw new LogicException('Cannot read the value of an omitted input field.'); + } + + return $this->value; + } +} diff --git a/src/Transformer/ArgumentsTransformer.php b/src/Transformer/ArgumentsTransformer.php index c79d12816..14b75b346 100644 --- a/src/Transformer/ArgumentsTransformer.php +++ b/src/Transformer/ArgumentsTransformer.php @@ -10,16 +10,21 @@ use GraphQL\Type\Definition\NonNull; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; +use Overblog\GraphQLBundle\Definition\Omittable; use Overblog\GraphQLBundle\Definition\Type\PhpEnumType; use Overblog\GraphQLBundle\Error\InvalidArgumentError; use Overblog\GraphQLBundle\Error\InvalidArgumentsError; +use ReflectionClass; +use ReflectionNamedType; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessor; use Symfony\Component\Validator\ConstraintViolationList; use Symfony\Component\Validator\Validator\ValidatorInterface; +use function array_key_exists; use function array_map; use function count; +use function is_a; use function is_array; use function is_object; use function sprintf; @@ -53,6 +58,20 @@ private function getTypeClassInstance(string $type) return $classname ? new $classname() : false; } + private function isOmittableProperty(object $instance, string $property): bool + { + $reflectionClass = new ReflectionClass($instance); + + if (!$reflectionClass->hasProperty($property)) { + return false; + } + + $reflectionType = $reflectionClass->getProperty($property)->getType(); + + return $reflectionType instanceof ReflectionNamedType + && is_a($reflectionType->getName(), Omittable::class, true); + } + /** * Extract given type from Resolve Info. */ @@ -104,10 +123,13 @@ private function populateObject(Type $type, $data, bool $multiple, ResolveInfo $ $fields = $type->getFields(); foreach ($fields as $name => $field) { - if ($field->defaultValueExists() && !array_key_exists($name, $data)) { + $isFieldProvided = array_key_exists($name, $data); + $isOmittableProperty = $this->isOmittableProperty($instance, $name); + + if ($field->defaultValueExists() && !$isFieldProvided && !$isOmittableProperty) { continue; } - $fieldData = $this->accessor->getValue($data, sprintf('[%s]', $name)); + $fieldData = $isFieldProvided ? $this->accessor->getValue($data, sprintf('[%s]', $name)) : null; $fieldType = $field->getType(); if ($fieldType instanceof NonNull) { @@ -120,6 +142,10 @@ private function populateObject(Type $type, $data, bool $multiple, ResolveInfo $ $fieldValue = $this->populateObject($fieldType, $fieldData, false, $info); } + if ($isOmittableProperty) { + $fieldValue = $isFieldProvided ? Omittable::set($fieldValue) : Omittable::omitted(); + } + $this->accessor->setValue($instance, $name, $fieldValue); } diff --git a/tests/Transformer/ArgumentsTransformerTest.php b/tests/Transformer/ArgumentsTransformerTest.php index 7dadbc6a7..8807b9a6c 100644 --- a/tests/Transformer/ArgumentsTransformerTest.php +++ b/tests/Transformer/ArgumentsTransformerTest.php @@ -13,6 +13,8 @@ use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; use GraphQL\Type\Schema; +use LogicException; +use Overblog\GraphQLBundle\Definition\Omittable; use Overblog\GraphQLBundle\Definition\Type\PhpEnumType; use Overblog\GraphQLBundle\Error\InvalidArgumentError; use Overblog\GraphQLBundle\Error\InvalidArgumentsError; @@ -110,7 +112,17 @@ public static function getTypes(): array ], ]); - $types = [$t1, $t2, $t3, $t4, $t5, $t6]; + $t7 = new InputObjectType([ + 'name' => 'InputTypeWithOmittable', + 'fields' => [ + 'nullableString' => Type::string(), + 'nestedInput' => $t1, + 'stringList' => Type::listOf(Type::string()), + 'regularNullable' => Type::string(), + ], + ]); + + $types = [$t1, $t2, $t3, $t4, $t5, $t6, $t7]; if (PHP_VERSION_ID >= 80100) { $types[] = new PhpEnumType([ @@ -253,6 +265,58 @@ public function testPopulating(): void $this->assertEquals('enum1', $res->field3->value); } + public function testPopulatingOmittableInputFields(): void + { + $transformer = $this->getTransformer([ + 'InputType1' => ['type' => 'input', 'class' => InputType1::class], + 'InputTypeWithOmittable' => ['type' => 'input', 'class' => InputTypeWithOmittable::class], + ]); + + $info = $this->getResolveInfo(self::getTypes()); + + /** @var InputTypeWithOmittable $res */ + $res = $transformer->getInstanceAndValidate( + 'InputTypeWithOmittable', + [ + 'nullableString' => null, + 'nestedInput' => ['field1' => 'nested value'], + 'stringList' => ['first', 'second'], + ], + $info, + 'input' + ); + + $this->assertInstanceOf(InputTypeWithOmittable::class, $res); + $this->assertInstanceOf(Omittable::class, $res->nullableString); + $this->assertTrue($res->nullableString->isSet()); + $this->assertNull($res->nullableString->value()); + + $this->assertTrue($res->nestedInput->isSet()); + $this->assertInstanceOf(InputType1::class, $res->nestedInput->value()); + $this->assertSame('nested value', $res->nestedInput->value()->field1); + + $this->assertTrue($res->stringList->isSet()); + $this->assertSame(['first', 'second'], $res->stringList->value()); + + $this->assertNull($res->regularNullable); + + /** @var InputTypeWithOmittable $res */ + $res = $transformer->getInstanceAndValidate('InputTypeWithOmittable', [], $info, 'input'); + + $this->assertFalse($res->nullableString->isSet()); + $this->assertFalse($res->nestedInput->isSet()); + $this->assertFalse($res->stringList->isSet()); + $this->assertNull($res->regularNullable); + } + + public function testOmittableValueCannotBeReadWhenOmitted(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot read the value of an omitted input field.'); + + Omittable::omitted()->value(); + } + public function testRaisedErrors(): void { $violation = new ConstraintViolation('validation_error', 'validation_error', [], 'invalid', 'field2', 'invalid'); diff --git a/tests/Transformer/InputTypeWithOmittable.php b/tests/Transformer/InputTypeWithOmittable.php new file mode 100644 index 000000000..50b8c72d0 --- /dev/null +++ b/tests/Transformer/InputTypeWithOmittable.php @@ -0,0 +1,27 @@ + + */ + public Omittable $nullableString; + + /** + * @var Omittable + */ + public Omittable $nestedInput; + + /** + * @var Omittable|null> + */ + public Omittable $stringList; + + public ?string $regularNullable = 'default_value'; +}