Skip to content

Commit 4a700d8

Browse files
Introduce Undefined::VALUE (#764)
* Introduce Undefined::VALUE * Fix failing tests * Deprecate update flag, update the docs * Add trigger_error function import to Input.php --------- Co-authored-by: Jacob Thomason <jacob@thomason.xxx>
1 parent 49e2065 commit 4a700d8

20 files changed

Lines changed: 367 additions & 37 deletions

src/Annotations/Input.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
use Attribute;
88
use RuntimeException;
99

10+
use function array_key_exists;
11+
use function trigger_error;
12+
13+
use const E_USER_DEPRECATED;
14+
1015
/**
1116
* The Input attribute must be put in a GraphQL input type class docblock and is used to map to the underlying PHP class
1217
* this is exposed via this input type.
@@ -33,6 +38,14 @@ public function __construct(
3338
string|null $description = null,
3439
bool|null $update = null,
3540
) {
41+
if ($update !== null || array_key_exists('update', $attributes)) {
42+
trigger_error(
43+
'Using #[Input(update: ...)] is deprecated and will be removed in a future major version. '
44+
. 'For partial updates, prefer nullable fields with Undefined to distinguish omitted values from explicit nulls.',
45+
E_USER_DEPRECATED,
46+
);
47+
}
48+
3649
$this->name = $name ?? $attributes['name'] ?? null;
3750
$this->default = $default ?? $attributes['default'] ?? $this->name === null;
3851
$this->description = $description ?? $attributes['description'] ?? null;
@@ -86,6 +99,8 @@ public function getDescription(): string|null
8699
/**
87100
* Returns true if this type should behave as update resource.
88101
* Such input type has all fields optional and without default value in the documentation.
102+
*
103+
* @deprecated Using #[Input(update: ...)] is deprecated; prefer nullable fields with Undefined.
89104
*/
90105
public function isUpdate(): bool
91106
{

src/FieldsBuilder.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1141,7 +1141,7 @@ private function getInputFieldsByPropertyAnnotations(
11411141
$name = $annotation->getName() ?: $refProperty->getName();
11421142
$inputType = $annotation->getInputType();
11431143
$constructerParameters = $this->getClassConstructParameterNames($refClass);
1144-
$inputProperty = $this->typeMapper->mapInputProperty($refProperty, $docBlock, $name, $inputType, $defaultProperties[$refProperty->getName()] ?? null, $isUpdate ? true : null);
1144+
$inputProperty = $this->typeMapper->mapInputProperty($refProperty, $docBlock, $name, $inputType, $defaultProperties[$refProperty->getName()] ?? null, $isUpdate ? true : null, isset($defaultProperties[$refProperty->getName()]));
11451145

11461146
$description = $this->descriptionResolver->resolve(
11471147
$annotation->getDescription(),

src/Mappers/Parameters/ResolveInfoParameterHandler.php

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,13 @@
1313
use TheCodingMachine\GraphQLite\Parameters\ParameterInterface;
1414
use TheCodingMachine\GraphQLite\Parameters\ResolveInfoParameter;
1515

16-
use function assert;
17-
1816
class ResolveInfoParameterHandler implements ParameterMiddlewareInterface
1917
{
2018
public function mapParameter(ReflectionParameter $parameter, DocBlock $docBlock, Type|null $paramTagType, ParameterAnnotations $parameterAnnotations, ParameterHandlerInterface $parameterMapper): ParameterInterface
2119
{
2220
$type = $parameter->getType();
23-
assert($type === null || $type instanceof ReflectionNamedType);
24-
if ($type !== null && $type->getName() === ResolveInfo::class) {
21+
22+
if ($type instanceof ReflectionNamedType && $type->getName() === ResolveInfo::class) {
2523
return new ResolveInfoParameter();
2624
}
2725

src/Mappers/Parameters/TypeHandler.php

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,15 @@
3939
use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException;
4040
use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeExceptionInterface;
4141
use TheCodingMachine\GraphQLite\Mappers\Root\RootTypeMapperInterface;
42+
use TheCodingMachine\GraphQLite\Mappers\Root\UndefinedTypeMapper;
4243
use TheCodingMachine\GraphQLite\Parameters\DefaultValueParameter;
4344
use TheCodingMachine\GraphQLite\Parameters\InputTypeParameter;
4445
use TheCodingMachine\GraphQLite\Parameters\InputTypeProperty;
4546
use TheCodingMachine\GraphQLite\Parameters\ParameterInterface;
4647
use TheCodingMachine\GraphQLite\Reflection\DocBlock\DocBlockFactory;
4748
use TheCodingMachine\GraphQLite\Types\ArgumentResolver;
4849
use TheCodingMachine\GraphQLite\Types\TypeResolver;
50+
use TheCodingMachine\GraphQLite\Undefined;
4951
use TheCodingMachine\GraphQLite\Utils\DescriptionResolver;
5052

5153
use function array_map;
@@ -179,6 +181,19 @@ public function mapParameter(
179181
return new DefaultValueParameter($parameter->getDefaultValue());
180182
}
181183

184+
$parameterType = $parameter->getType();
185+
$allowsNull = $parameterType === null || $parameterType->allowsNull();
186+
187+
if ($parameterType === null) {
188+
$phpdocType = new Mixed_();
189+
$allowsNull = false;
190+
//throw MissingTypeHintException::missingTypeHint($parameter);
191+
} else {
192+
$declaringClass = $parameter->getDeclaringClass();
193+
assert($declaringClass !== null);
194+
$phpdocType = $this->reflectionTypeToPhpDocType($parameterType, $declaringClass);
195+
}
196+
182197
$useInputType = $parameterAnnotations->getAnnotationByType(UseInputType::class);
183198
if ($useInputType !== null) {
184199
try {
@@ -188,19 +203,6 @@ public function mapParameter(
188203
throw $e;
189204
}
190205
} else {
191-
$parameterType = $parameter->getType();
192-
$allowsNull = $parameterType === null || $parameterType->allowsNull();
193-
194-
if ($parameterType === null) {
195-
$phpdocType = new Mixed_();
196-
$allowsNull = false;
197-
//throw MissingTypeHintException::missingTypeHint($parameter);
198-
} else {
199-
$declaringClass = $parameter->getDeclaringClass();
200-
assert($declaringClass !== null);
201-
$phpdocType = $this->reflectionTypeToPhpDocType($parameterType, $declaringClass);
202-
}
203-
204206
try {
205207
$declaringFunction = $parameter->getDeclaringFunction();
206208
if (! $declaringFunction instanceof ReflectionMethod) {
@@ -228,20 +230,31 @@ public function mapParameter(
228230

229231
$hasDefaultValue = false;
230232
$defaultValue = null;
231-
if ($parameter->allowsNull()) {
232-
$hasDefaultValue = true;
233-
}
233+
234234
if ($parameter->isDefaultValueAvailable()) {
235235
$hasDefaultValue = true;
236236
$defaultValue = $parameter->getDefaultValue();
237237
}
238238

239+
if (! $hasDefaultValue && UndefinedTypeMapper::containsUndefined($phpdocType)) {
240+
$hasDefaultValue = true;
241+
$defaultValue = Undefined::VALUE;
242+
}
243+
244+
if (! $hasDefaultValue && $parameter->allowsNull()) {
245+
$hasDefaultValue = true;
246+
$defaultValue = null;
247+
}
248+
249+
$description = $this->getParameterDescriptionFromDocBlock($docBlock, $parameter);
250+
239251
return new InputTypeParameter(
240252
name: $parameter->getName(),
241253
type: $type,
242254
description: $description,
243255
hasDefaultValue: $hasDefaultValue,
244256
defaultValue: $defaultValue,
257+
defaultValueImplicit: $defaultValue === Undefined::VALUE,
245258
argumentResolver: $this->argumentResolver,
246259
);
247260
}
@@ -311,6 +324,7 @@ public function mapInputProperty(
311324
string|null $inputTypeName = null,
312325
mixed $defaultValue = null,
313326
bool|null $isNullable = null,
327+
bool $hasDefaultValue = false,
314328
): InputTypeProperty
315329
{
316330
$docBlockDescription = $docBlock->getSummary() . PHP_EOL . $docBlock->getDescription()->render();
@@ -333,14 +347,30 @@ public function mapInputProperty(
333347
$isNullable = $refProperty->getType()?->allowsNull() ?? false;
334348
}
335349

350+
$propertyType = $refProperty->getType();
351+
if ($propertyType !== null) {
352+
$phpdocType = $this->reflectionTypeToPhpDocType($propertyType, $refProperty->getDeclaringClass());
353+
} else {
354+
$phpdocType = new Mixed_();
355+
}
356+
336357
if ($inputTypeName) {
337358
$inputType = $this->typeResolver->mapNameToInputType($inputTypeName);
338359
} else {
339360
$inputType = $this->mapPropertyType($refProperty, $docBlock, true, $argumentName, $isNullable);
340361
assert($inputType instanceof InputType);
341362
}
342363

343-
$hasDefault = $defaultValue !== null || $isNullable;
364+
if (! $hasDefaultValue && $isNullable) {
365+
$hasDefaultValue = true;
366+
$defaultValue = null;
367+
}
368+
369+
if (! $hasDefaultValue && UndefinedTypeMapper::containsUndefined($phpdocType)) {
370+
$hasDefaultValue = true;
371+
$defaultValue = Undefined::VALUE;
372+
}
373+
344374
$fieldName = $argumentName ?? $refProperty->getName();
345375

346376
$resolvedDescription = $this->descriptionResolver->resolve(null, $docBlockDescription);
@@ -350,8 +380,9 @@ public function mapInputProperty(
350380
fieldName: $fieldName,
351381
type: $inputType,
352382
description: $resolvedDescription !== null ? trim($resolvedDescription) : '',
353-
hasDefaultValue: $hasDefault,
383+
hasDefaultValue: $hasDefaultValue,
354384
defaultValue: $defaultValue,
385+
defaultValueImplicit: $defaultValue === Undefined::VALUE,
355386
argumentResolver: $this->argumentResolver,
356387
);
357388
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Mappers\Root;
6+
7+
use GraphQL\Type\Definition\InputType;
8+
use GraphQL\Type\Definition\NamedType;
9+
use GraphQL\Type\Definition\OutputType;
10+
use GraphQL\Type\Definition\Type as GraphQLType;
11+
use phpDocumentor\Reflection\DocBlock;
12+
use phpDocumentor\Reflection\Type;
13+
use phpDocumentor\Reflection\Types\Compound;
14+
use phpDocumentor\Reflection\Types\Null_;
15+
use phpDocumentor\Reflection\Types\Nullable;
16+
use phpDocumentor\Reflection\Types\Object_;
17+
use ReflectionMethod;
18+
use ReflectionProperty;
19+
use TheCodingMachine\GraphQLite\Undefined;
20+
21+
use function array_map;
22+
use function array_values;
23+
use function iterator_to_array;
24+
use function ltrim;
25+
26+
/**
27+
* A root type mapper for {@see Undefined} that maps replaces those with `null` as if Undefined wasn't part of the type at all.
28+
*/
29+
class UndefinedTypeMapper implements RootTypeMapperInterface
30+
{
31+
public function __construct(
32+
private readonly RootTypeMapperInterface $next,
33+
) {
34+
}
35+
36+
public function toGraphQLOutputType(Type $type, OutputType|null $subType, ReflectionMethod|ReflectionProperty $reflector, DocBlock $docBlockObj): OutputType&GraphQLType
37+
{
38+
return $this->next->toGraphQLOutputType($type, $subType, $reflector, $docBlockObj);
39+
}
40+
41+
public function toGraphQLInputType(Type $type, InputType|null $subType, string $argumentName, ReflectionMethod|ReflectionProperty $reflector, DocBlock $docBlockObj): InputType&GraphQLType
42+
{
43+
$type = self::replaceUndefinedWith($type);
44+
45+
return $this->next->toGraphQLInputType($type, $subType, $argumentName, $reflector, $docBlockObj);
46+
}
47+
48+
public function mapNameToType(string $typeName): NamedType&GraphQLType
49+
{
50+
return $this->next->mapNameToType($typeName);
51+
}
52+
53+
/**
54+
* Replaces types like this: `int|Undefined` to `int|null`
55+
*/
56+
public static function replaceUndefinedWith(Type $type, Type $replaceWith = new Null_()): Type
57+
{
58+
if ($type instanceof Object_ && ltrim((string) $type->getFqsen(), '\\') === Undefined::class) {
59+
return $replaceWith;
60+
}
61+
62+
if ($type instanceof Nullable) {
63+
return new Nullable(self::replaceUndefinedWith($type->getActualType(), $replaceWith));
64+
}
65+
66+
if ($type instanceof Compound) {
67+
$types = array_map(static fn (Type $type) => self::replaceUndefinedWith($type, $replaceWith), iterator_to_array($type));
68+
69+
return new Compound(array_values($types));
70+
}
71+
72+
return $type;
73+
}
74+
75+
public static function containsUndefined(Type $type): bool
76+
{
77+
return (string) $type !== (string) self::replaceUndefinedWith($type);
78+
}
79+
}

src/Parameters/InputTypeParameter.php

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
use TheCodingMachine\GraphQLite\Types\ArgumentResolver;
1111
use TheCodingMachine\GraphQLite\Types\ResolvableMutableInputObjectType;
1212

13+
use function array_key_exists;
14+
1315
class InputTypeParameter implements InputTypeParameterInterface
1416
{
1517
public function __construct(
@@ -18,6 +20,7 @@ public function __construct(
1820
private readonly string|null $description,
1921
private readonly bool $hasDefaultValue,
2022
private readonly mixed $defaultValue,
23+
private readonly bool $defaultValueImplicit,
2124
private readonly ArgumentResolver $argumentResolver,
2225
)
2326
{
@@ -26,7 +29,7 @@ public function __construct(
2629
/** @param array<string, mixed> $args */
2730
public function resolve(object|null $source, array $args, mixed $context, ResolveInfo $info): mixed
2831
{
29-
if (isset($args[$this->name])) {
32+
if (array_key_exists($this->name, $args)) {
3033
return $this->argumentResolver->resolve($source, $args[$this->name], $context, $info, $this->type);
3134
}
3235

@@ -55,12 +58,18 @@ public function getType(): InputType&Type
5558

5659
public function hasDefaultValue(): bool
5760
{
58-
return $this->hasDefaultValue;
61+
// Unfortunately, we can't treat Undefined as a regular kind of default value. In this context,
62+
// $defaultValue refers to the default value on GraphQL level - e.g. the value that's printed
63+
// into the schema, returned in introspection and substituted by webonyx/graphql when a GraphQL
64+
// query is executed. Unlike regular defaults, this one shouldn't be treated as such -
65+
// because GraphQL itself doesn't have a concept of undefined values, at least not on Schema level.
66+
// It would fail to serialize during printing/introspection.
67+
return $this->hasDefaultValue && ! $this->defaultValueImplicit;
5968
}
6069

6170
public function getDefaultValue(): mixed
6271
{
63-
return $this->defaultValue;
72+
return ! $this->defaultValueImplicit ? $this->defaultValue : null;
6473
}
6574

6675
public function getDescription(): string

src/Parameters/InputTypeProperty.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public function __construct(
1717
string $description,
1818
bool $hasDefaultValue,
1919
mixed $defaultValue,
20+
bool $defaultValueImplicit,
2021
ArgumentResolver $argumentResolver,
2122
)
2223
{
@@ -26,6 +27,7 @@ public function __construct(
2627
$description,
2728
$hasDefaultValue,
2829
$defaultValue,
30+
$defaultValueImplicit,
2931
$argumentResolver,
3032
);
3133
}

src/SchemaFactory.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
use TheCodingMachine\GraphQLite\Mappers\Root\NullableTypeMapperAdapter;
4444
use TheCodingMachine\GraphQLite\Mappers\Root\RootTypeMapperFactoryContext;
4545
use TheCodingMachine\GraphQLite\Mappers\Root\RootTypeMapperFactoryInterface;
46+
use TheCodingMachine\GraphQLite\Mappers\Root\UndefinedTypeMapper;
4647
use TheCodingMachine\GraphQLite\Mappers\Root\VoidTypeMapper;
4748
use TheCodingMachine\GraphQLite\Mappers\TypeMapperFactoryInterface;
4849
use TheCodingMachine\GraphQLite\Mappers\TypeMapperInterface;
@@ -421,6 +422,7 @@ public function createSchema(): Schema
421422

422423
$lastTopRootTypeMapper = new LastDelegatingTypeMapper();
423424
$topRootTypeMapper = new NullableTypeMapperAdapter($lastTopRootTypeMapper);
425+
$topRootTypeMapper = new UndefinedTypeMapper($topRootTypeMapper);
424426
$topRootTypeMapper = new VoidTypeMapper($topRootTypeMapper);
425427
$topRootTypeMapper = new ClosureTypeMapper($topRootTypeMapper, $lastTopRootTypeMapper);
426428

src/Types/ArgumentResolver.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,12 @@ class ArgumentResolver
3232
*/
3333
public function resolve(object|null $source, mixed $val, mixed $context, ResolveInfo $resolveInfo, InputType&Type $type): mixed
3434
{
35+
if ($val === null && ! $type instanceof NonNull) {
36+
return null;
37+
}
38+
3539
$type = $this->stripNonNullType($type);
40+
3641
if ($type instanceof ListOfType) {
3742
if (! is_array($val)) {
3843
throw new InvalidArgumentException('Expected GraphQL List but value passed is not an array.');

src/Undefined.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite;
6+
7+
/**
8+
* Represents a special marker type used to distinguish between an explicitly
9+
* provided `null` value and an absent (missing) field in the input payload.
10+
*/
11+
enum Undefined
12+
{
13+
case VALUE;
14+
}

0 commit comments

Comments
 (0)