Skip to content

Commit 7fef0c9

Browse files
committed
Preserve omitted input fields in DTO hydration
1 parent 6c49418 commit 7fef0c9

5 files changed

Lines changed: 226 additions & 3 deletions

File tree

docs/attributes/arguments-transformer.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,49 @@ class RootMutation {
7373
```
7474

7575
So, the resolver (the `createUser` method) will receive an instance of the class `UserRegisterInput` instead of an array of data.
76+
77+
## Preserving omitted input fields
78+
79+
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`.
80+
81+
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`.
82+
83+
```php
84+
namespace App\GraphQL\Input;
85+
86+
use Overblog\GraphQLBundle\Annotation as GQL;
87+
use Overblog\GraphQLBundle\Definition\Omittable;
88+
89+
#[GQL\Input]
90+
class UpdateUserInput {
91+
/**
92+
* @var Omittable<string|null>
93+
*/
94+
#[GQL\Field(type: "String")]
95+
public Omittable $phone;
96+
}
97+
```
98+
99+
The GraphQL schema field is still a regular nullable `String`. Only the hydrated PHP property changes:
100+
101+
```php
102+
if ($input->phone->isSet()) {
103+
// The client provided phone, either as null or as a string.
104+
$user->setPhone($input->phone->value());
105+
}
106+
```
107+
108+
The possible states are:
109+
110+
```php
111+
// phone was omitted
112+
$input->phone->isSet() === false;
113+
114+
// phone was explicitly set to null
115+
$input->phone->isSet() === true;
116+
$input->phone->value() === null;
117+
118+
// phone was provided with a value
119+
$input->phone->isSet() === true;
120+
$input->phone->value() === '+123';
121+
```

src/Definition/Omittable.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Overblog\GraphQLBundle\Definition;
6+
7+
use LogicException;
8+
9+
/**
10+
* @template T
11+
*/
12+
final class Omittable
13+
{
14+
/**
15+
* @param T|null $value
16+
*/
17+
private function __construct(
18+
private readonly bool $isSet,
19+
private readonly mixed $value = null,
20+
) {
21+
}
22+
23+
/**
24+
* @return self<T>
25+
*/
26+
public static function omitted(): self
27+
{
28+
/** @var self<T> $omitted */
29+
$omitted = new self(false);
30+
31+
return $omitted;
32+
}
33+
34+
/**
35+
* @param T $value
36+
*
37+
* @return self<T>
38+
*/
39+
public static function set(mixed $value): self
40+
{
41+
return new self(true, $value);
42+
}
43+
44+
public function isSet(): bool
45+
{
46+
return $this->isSet;
47+
}
48+
49+
/**
50+
* @return T
51+
*/
52+
public function value(): mixed
53+
{
54+
if (!$this->isSet) {
55+
throw new LogicException('Cannot read the value of an omitted input field.');
56+
}
57+
58+
return $this->value;
59+
}
60+
}

src/Transformer/ArgumentsTransformer.php

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,21 @@
1010
use GraphQL\Type\Definition\NonNull;
1111
use GraphQL\Type\Definition\ResolveInfo;
1212
use GraphQL\Type\Definition\Type;
13+
use Overblog\GraphQLBundle\Definition\Omittable;
1314
use Overblog\GraphQLBundle\Definition\Type\PhpEnumType;
1415
use Overblog\GraphQLBundle\Error\InvalidArgumentError;
1516
use Overblog\GraphQLBundle\Error\InvalidArgumentsError;
17+
use ReflectionClass;
18+
use ReflectionNamedType;
1619
use Symfony\Component\PropertyAccess\PropertyAccess;
1720
use Symfony\Component\PropertyAccess\PropertyAccessor;
1821
use Symfony\Component\Validator\ConstraintViolationList;
1922
use Symfony\Component\Validator\Validator\ValidatorInterface;
2023

24+
use function array_key_exists;
2125
use function array_map;
2226
use function count;
27+
use function is_a;
2328
use function is_array;
2429
use function is_object;
2530
use function sprintf;
@@ -53,6 +58,20 @@ private function getTypeClassInstance(string $type)
5358
return $classname ? new $classname() : false;
5459
}
5560

61+
private function isOmittableProperty(object $instance, string $property): bool
62+
{
63+
$reflectionClass = new ReflectionClass($instance);
64+
65+
if (!$reflectionClass->hasProperty($property)) {
66+
return false;
67+
}
68+
69+
$reflectionType = $reflectionClass->getProperty($property)->getType();
70+
71+
return $reflectionType instanceof ReflectionNamedType
72+
&& is_a($reflectionType->getName(), Omittable::class, true);
73+
}
74+
5675
/**
5776
* Extract given type from Resolve Info.
5877
*/
@@ -104,10 +123,13 @@ private function populateObject(Type $type, $data, bool $multiple, ResolveInfo $
104123
$fields = $type->getFields();
105124

106125
foreach ($fields as $name => $field) {
107-
if ($field->defaultValueExists() && !array_key_exists($name, $data)) {
126+
$isFieldProvided = array_key_exists($name, $data);
127+
$isOmittableProperty = $this->isOmittableProperty($instance, $name);
128+
129+
if ($field->defaultValueExists() && !$isFieldProvided && !$isOmittableProperty) {
108130
continue;
109131
}
110-
$fieldData = $this->accessor->getValue($data, sprintf('[%s]', $name));
132+
$fieldData = $isFieldProvided ? $this->accessor->getValue($data, sprintf('[%s]', $name)) : null;
111133
$fieldType = $field->getType();
112134

113135
if ($fieldType instanceof NonNull) {
@@ -120,6 +142,10 @@ private function populateObject(Type $type, $data, bool $multiple, ResolveInfo $
120142
$fieldValue = $this->populateObject($fieldType, $fieldData, false, $info);
121143
}
122144

145+
if ($isOmittableProperty) {
146+
$fieldValue = $isFieldProvided ? Omittable::set($fieldValue) : Omittable::omitted();
147+
}
148+
123149
$this->accessor->setValue($instance, $name, $fieldValue);
124150
}
125151

tests/Transformer/ArgumentsTransformerTest.php

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
use GraphQL\Type\Definition\ResolveInfo;
1414
use GraphQL\Type\Definition\Type;
1515
use GraphQL\Type\Schema;
16+
use LogicException;
17+
use Overblog\GraphQLBundle\Definition\Omittable;
1618
use Overblog\GraphQLBundle\Definition\Type\PhpEnumType;
1719
use Overblog\GraphQLBundle\Error\InvalidArgumentError;
1820
use Overblog\GraphQLBundle\Error\InvalidArgumentsError;
@@ -110,7 +112,17 @@ public static function getTypes(): array
110112
],
111113
]);
112114

113-
$types = [$t1, $t2, $t3, $t4, $t5, $t6];
115+
$t7 = new InputObjectType([
116+
'name' => 'InputTypeWithOmittable',
117+
'fields' => [
118+
'nullableString' => Type::string(),
119+
'nestedInput' => $t1,
120+
'stringList' => Type::listOf(Type::string()),
121+
'regularNullable' => Type::string(),
122+
],
123+
]);
124+
125+
$types = [$t1, $t2, $t3, $t4, $t5, $t6, $t7];
114126

115127
if (PHP_VERSION_ID >= 80100) {
116128
$types[] = new PhpEnumType([
@@ -253,6 +265,58 @@ public function testPopulating(): void
253265
$this->assertEquals('enum1', $res->field3->value);
254266
}
255267

268+
public function testPopulatingOmittableInputFields(): void
269+
{
270+
$transformer = $this->getTransformer([
271+
'InputType1' => ['type' => 'input', 'class' => InputType1::class],
272+
'InputTypeWithOmittable' => ['type' => 'input', 'class' => InputTypeWithOmittable::class],
273+
]);
274+
275+
$info = $this->getResolveInfo(self::getTypes());
276+
277+
/** @var InputTypeWithOmittable $res */
278+
$res = $transformer->getInstanceAndValidate(
279+
'InputTypeWithOmittable',
280+
[
281+
'nullableString' => null,
282+
'nestedInput' => ['field1' => 'nested value'],
283+
'stringList' => ['first', 'second'],
284+
],
285+
$info,
286+
'input'
287+
);
288+
289+
$this->assertInstanceOf(InputTypeWithOmittable::class, $res);
290+
$this->assertInstanceOf(Omittable::class, $res->nullableString);
291+
$this->assertTrue($res->nullableString->isSet());
292+
$this->assertNull($res->nullableString->value());
293+
294+
$this->assertTrue($res->nestedInput->isSet());
295+
$this->assertInstanceOf(InputType1::class, $res->nestedInput->value());
296+
$this->assertSame('nested value', $res->nestedInput->value()->field1);
297+
298+
$this->assertTrue($res->stringList->isSet());
299+
$this->assertSame(['first', 'second'], $res->stringList->value());
300+
301+
$this->assertNull($res->regularNullable);
302+
303+
/** @var InputTypeWithOmittable $res */
304+
$res = $transformer->getInstanceAndValidate('InputTypeWithOmittable', [], $info, 'input');
305+
306+
$this->assertFalse($res->nullableString->isSet());
307+
$this->assertFalse($res->nestedInput->isSet());
308+
$this->assertFalse($res->stringList->isSet());
309+
$this->assertNull($res->regularNullable);
310+
}
311+
312+
public function testOmittableValueCannotBeReadWhenOmitted(): void
313+
{
314+
$this->expectException(LogicException::class);
315+
$this->expectExceptionMessage('Cannot read the value of an omitted input field.');
316+
317+
Omittable::omitted()->value();
318+
}
319+
256320
public function testRaisedErrors(): void
257321
{
258322
$violation = new ConstraintViolation('validation_error', 'validation_error', [], 'invalid', 'field2', 'invalid');
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Overblog\GraphQLBundle\Tests\Transformer;
6+
7+
use Overblog\GraphQLBundle\Definition\Omittable;
8+
9+
final class InputTypeWithOmittable
10+
{
11+
/**
12+
* @var Omittable<string|null>
13+
*/
14+
public Omittable $nullableString;
15+
16+
/**
17+
* @var Omittable<InputType1|null>
18+
*/
19+
public Omittable $nestedInput;
20+
21+
/**
22+
* @var Omittable<array<string>|null>
23+
*/
24+
public Omittable $stringList;
25+
26+
public ?string $regularNullable = 'default_value';
27+
}

0 commit comments

Comments
 (0)