Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/attributes/arguments-transformer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string|null>
*/
#[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';
```
60 changes: 60 additions & 0 deletions src/Definition/Omittable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);

namespace Overblog\GraphQLBundle\Definition;

use LogicException;

/**
* @phpstan-template T
*/
final class Omittable
{
/**
* @param T|null $value
*/
private function __construct(
private readonly bool $isSet,
private readonly mixed $value = null,
) {
}

/**
* @return self<T>
*/
public static function omitted(): self
{
/** @var self<T> $omitted */
$omitted = new self(false);

return $omitted;
}

/**
* @param T $value
*
* @return self<T>
*/
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;
}
}
30 changes: 28 additions & 2 deletions src/Transformer/ArgumentsTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}

Expand Down
66 changes: 65 additions & 1 deletion tests/Transformer/ArgumentsTransformerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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');
Expand Down
27 changes: 27 additions & 0 deletions tests/Transformer/InputTypeWithOmittable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Overblog\GraphQLBundle\Tests\Transformer;

use Overblog\GraphQLBundle\Definition\Omittable;

final class InputTypeWithOmittable
{
/**
* @var Omittable<string|null>
*/
public Omittable $nullableString;

/**
* @var Omittable<InputType1|null>
*/
public Omittable $nestedInput;

/**
* @var Omittable<array<string>|null>
*/
public Omittable $stringList;

public ?string $regularNullable = 'default_value';
}
Loading