Skip to content
Merged
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
85 changes: 82 additions & 3 deletions src/FieldsBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
use TheCodingMachine\GraphQLite\Middlewares\SourceInputPropertyResolver;
use TheCodingMachine\GraphQLite\Middlewares\SourceMethodResolver;
use TheCodingMachine\GraphQLite\Middlewares\SourcePropertyResolver;
use TheCodingMachine\GraphQLite\Parameters\InputTypeParameter;
use TheCodingMachine\GraphQLite\Parameters\InputTypeParameterInterface;
use TheCodingMachine\GraphQLite\Parameters\ParameterInterface;
use TheCodingMachine\GraphQLite\Parameters\PrefetchDataParameter;
Expand All @@ -57,6 +58,7 @@
use function array_diff_key;
use function array_fill_keys;
use function array_intersect_key;
use function array_key_exists;
use function array_keys;
use function array_merge;
use function array_shift;
Expand Down Expand Up @@ -1069,6 +1071,11 @@ private function getInputFieldsByMethodAnnotations(

assert($type instanceof InputType);

$parameter = $args[$name];
$descriptionItems = $parameter instanceof InputTypeParameter && $parameter->acceptsUndefined() && ! $type instanceof NonNull
? $this->descriptionResolver->describeUndefined($parameter->refusesNull())
: [];

$resolver = new SourceMethodResolver($refMethod);

$inputFieldDescriptor = new InputFieldDescriptor(
Expand All @@ -1079,10 +1086,11 @@ private function getInputFieldsByMethodAnnotations(
parameters: $args,
injectSource: $injectSource,
description: $description !== null ? trim($description) : null,
descriptionItems: $descriptionItems,
middlewareAnnotations: $this->annotationReader->getMiddlewareAnnotations($refMethod),
isUpdate: $isUpdate,
hasDefaultValue: $isUpdate,
defaultValue: $args[$name]->getDefaultValue(),
defaultValue: $parameter->getDefaultValue(),
);

$field = $this->inputFieldMiddleware->process($inputFieldDescriptor, new class implements InputFieldHandlerInterface {
Expand Down Expand Up @@ -1142,7 +1150,19 @@ 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, isset($defaultProperties[$refProperty->getName()]));
$forConstructorHydration = in_array($name, $constructerParameters);
$constructorParameter = $forConstructorHydration ? $this->findConstructorParameter($refClass, $name) : null;
$hasDefaultValue = $this->hasDefaultValue($refProperty, $defaultProperties);
$inputProperty = $this->typeMapper->mapInputProperty(
$refProperty,
$docBlock,
$name,
$inputType,
$defaultProperties[$refProperty->getName()] ?? null,
$isUpdate ? true : null,
$hasDefaultValue,
$this->hydrationAcceptsNull($refClass, $refProperty, $constructorParameter),
);

$description = $this->descriptionResolver->resolve(
$annotation->getDescription(),
Expand All @@ -1154,7 +1174,11 @@ private function getInputFieldsByPropertyAnnotations(
$type = $type->getWrappedType();
}
assert($type instanceof InputType);
$forConstructorHydration = in_array($name, $constructerParameters);

$descriptionItems = $inputProperty->acceptsUndefined() && ! $type instanceof NonNull
? $this->descriptionResolver->describeUndefined($inputProperty->refusesNull())
: [];

$resolver = $forConstructorHydration
? new SourceConstructorParameterResolver(
$refProperty->getDeclaringClass()->getName(),
Expand All @@ -1172,10 +1196,14 @@ private function getInputFieldsByPropertyAnnotations(
injectSource: false,
forConstructorHydration: $forConstructorHydration,
description: $description !== null ? trim($description) : null,
descriptionItems: $descriptionItems,
middlewareAnnotations: $this->annotationReader->getMiddlewareAnnotations($refProperty),
isUpdate: $isUpdate,
hasDefaultValue: $inputProperty->hasDefaultValue(),
defaultValue: $inputProperty->getDefaultValue(),
// Otherwise an omitted field leaves the property uninitialized, or the constructor without its argument
undefinedWhenOmitted: $inputProperty->isDefaultValueUndefined()
&& ! ($constructorParameter?->isDefaultValueAvailable() ?? $hasDefaultValue),
);

$field = $this->inputFieldMiddleware->process($inputFieldDescriptor, new class implements InputFieldHandlerInterface {
Expand All @@ -1195,6 +1223,57 @@ public function handle(InputFieldDescriptor $inputFieldDescriptor): InputField
return $fields;
}

/**
* Whether the constructor parameter or setter that receives an input property's value accepts null
*
* Mirrors {@see PropertyAccessor::setValue()}. Null when neither receives it, so the property type decides.
*/
private function hydrationAcceptsNull(
ReflectionClass $refClass,
ReflectionProperty $refProperty,
ReflectionParameter|null $constructorParameter,
): bool|null
{
if ($constructorParameter !== null) {
return $constructorParameter->allowsNull();
}

$setter = PropertyAccessor::findSetter($refClass->getName(), $refProperty->getName());
if ($setter === null) {
return null;
}

$parameters = $refClass->getMethod($setter)->getParameters();

return ! isset($parameters[0]) || $parameters[0]->allowsNull();
}

private function findConstructorParameter(ReflectionClass $refClass, string $name): ReflectionParameter|null
{
foreach ($refClass->getConstructor()?->getParameters() ?? [] as $parameter) {
if ($parameter->getName() === $name) {
return $parameter;
}
}

return null;
}

/**
* Whether an input property has a PHP default
*
* An untyped property's `null` default can't be told apart from no default at all, so it counts as none.
*
* @param array<string, mixed> $defaultProperties
*/
private function hasDefaultValue(ReflectionProperty $refProperty, array $defaultProperties): bool
{
$name = $refProperty->getName();

return isset($defaultProperties[$name])
|| ($refProperty->hasType() && array_key_exists($name, $defaultProperties));
}

/** @return string[] */
private function getClassConstructParameterNames(ReflectionClass $refClass): array
{
Expand Down
44 changes: 42 additions & 2 deletions src/InputField.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@
use GraphQL\Type\Definition\NonNull;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;
use ReflectionProperty;
use TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException;
use TheCodingMachine\GraphQLite\Middlewares\ResolverInterface;
use TheCodingMachine\GraphQLite\Middlewares\SourceInputPropertyResolver;
use TheCodingMachine\GraphQLite\Parameters\MissingArgumentException;
use TheCodingMachine\GraphQLite\Parameters\ParameterInterface;
use TheCodingMachine\GraphQLite\Parameters\SourceParameter;
use TheCodingMachine\GraphQLite\Utils\DescriptionResolver;
use Throwable;

/**
Expand All @@ -40,14 +43,15 @@ public function __construct(
string $name,
InputType $type,
array $arguments,
ResolverInterface $originalResolver,
private readonly ResolverInterface $originalResolver,
callable $resolver,
private bool $forConstructorHydration,
string|null $description,
bool $isUpdate,
bool $hasDefaultValue,
mixed $defaultValue,
array|null $additionalConfig = null,
private readonly bool $undefinedWhenOmitted = false,
) {
$config = [
'name' => $name,
Expand Down Expand Up @@ -122,6 +126,41 @@ public function forConstructorHydration(): bool
return $this->forConstructorHydration;
}

/** Whether an omitted field hydrates its constructor parameter or property with Undefined */
public function isUndefinedWhenOmitted(): bool
{
return $this->undefinedWhenOmitted;
}

/**
* Hydrates an omitted field with Undefined, returning the field's value
*
* Skips the middleware: nothing was sent, so there is nothing to authorize. A property the constructor
* already assigned keeps that value, as it would without Undefined (and a readonly one can't be reassigned).
*/
public function resolveOmitted(object|null $source): mixed
{
$resolver = $this->originalResolver;
if ($source !== null && $resolver instanceof SourceInputPropertyResolver) {
$property = $resolver->propertyReflection();
if (self::isAssigned($property, $source)) {
return $property->getValue($source);
}
}

return $resolver($source, Undefined::VALUE);
}

/** Whether the property holds a value, given an untyped property starts out null rather than uninitialized */
private static function isAssigned(ReflectionProperty $property, object $source): bool
{
if ($property->hasType()) {
return $property->isInitialized($source);
}

return $property->getValue($source) !== null;
}

private static function fromDescriptor(InputFieldDescriptor $fieldDescriptor): self
{
return new self(
Expand All @@ -131,10 +170,11 @@ private static function fromDescriptor(InputFieldDescriptor $fieldDescriptor): s
$fieldDescriptor->getOriginalResolver(),
$fieldDescriptor->getResolver(),
$fieldDescriptor->isForConstructorHydration(),
$fieldDescriptor->getDescription(),
DescriptionResolver::appendItems($fieldDescriptor->getDescription(), $fieldDescriptor->getDescriptionItems()),
$fieldDescriptor->isUpdate(),
$fieldDescriptor->hasDefaultValue(),
$fieldDescriptor->getDefaultValue(),
undefinedWhenOmitted: $fieldDescriptor->isUndefinedWhenOmitted(),
);
}

Expand Down
30 changes: 30 additions & 0 deletions src/InputFieldDescriptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class InputFieldDescriptor
* @param array<string, ParameterInterface> $parameters
* @param callable $resolver
* @param bool $injectSource Whether we should inject the source as the first parameter or not.
* @param list<string> $descriptionItems Metadata rendered as a list after the description
*/
public function __construct(
private readonly string $name,
Expand All @@ -41,10 +42,23 @@ public function __construct(
private readonly bool $isUpdate = false,
private readonly bool $hasDefaultValue = false,
private readonly mixed $defaultValue = null,
private readonly bool $undefinedWhenOmitted = false,
private readonly array $descriptionItems = [],
)
{
}

/** Whether an omitted field hydrates its constructor parameter or property with Undefined */
public function isUndefinedWhenOmitted(): bool
{
return $this->undefinedWhenOmitted;
}

public function withUndefinedWhenOmitted(bool $undefinedWhenOmitted): self
{
return $this->with(undefinedWhenOmitted: $undefinedWhenOmitted);
}

public function isUpdate(): bool
{
return $this->isUpdate;
Expand Down Expand Up @@ -137,6 +151,22 @@ public function withDescription(string|null $description): self
return $this->with(description: $description);
}

/** @return list<string> */
public function getDescriptionItems(): array
{
return $this->descriptionItems;
}

/**
* Appends a metadata item, rendered as a Markdown list after the description
*
* Items keep the order they were added in and survive {@see withDescription()}.
*/
public function withAddedDescriptionItem(string $item): self
{
return $this->with(descriptionItems: [...$this->descriptionItems, $item]);
}

public function getMiddlewareAnnotations(): MiddlewareAnnotations
{
return $this->middlewareAnnotations;
Expand Down
41 changes: 35 additions & 6 deletions src/Mappers/Parameters/TypeHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace TheCodingMachine\GraphQLite\Mappers\Parameters;

use GraphQL\Type\Definition\InputType;
use GraphQL\Type\Definition\NonNull;
use GraphQL\Type\Definition\OutputType;
use GraphQL\Type\Definition\Type as GraphQLType;
use InvalidArgumentException;
Expand Down Expand Up @@ -237,7 +238,9 @@ public function mapParameter(
$defaultValue = $parameter->getDefaultValue();
}

if (! $hasDefaultValue && UndefinedTypeMapper::containsUndefined($phpdocType)) {
$containsUndefined = self::containsUndefined($phpdocType, $paramTagType);

if (! $hasDefaultValue && $containsUndefined) {
$hasDefaultValue = true;
$defaultValue = Undefined::VALUE;
}
Expand All @@ -247,19 +250,36 @@ public function mapParameter(
$defaultValue = null;
}

$description = $this->getParameterDescriptionFromDocBlock($docBlock, $parameter);
$refusesNull = $containsUndefined && ! $parameter->allowsNull();
$descriptionItems = $containsUndefined && ! $type instanceof NonNull
? $this->descriptionResolver->describeUndefined($refusesNull)
: [];

return new InputTypeParameter(
name: $parameter->getName(),
type: $type,
description: $description,
descriptionItems: $descriptionItems,
hasDefaultValue: $hasDefaultValue,
defaultValue: $defaultValue,
defaultValueImplicit: $defaultValue === Undefined::VALUE,
argumentResolver: $this->argumentResolver,
refusesNull: $refusesNull,
acceptsUndefined: $containsUndefined,
);
}

/** Whether the native type includes Undefined, or the docblock type does when the native type is absent or mixed */
private static function containsUndefined(Type $phpdocType, Type|null $docBlockType): bool
{
$nativeType = $phpdocType instanceof Nullable ? $phpdocType->getActualType() : $phpdocType;
if ($nativeType instanceof Mixed_ && $docBlockType !== null) {
return UndefinedTypeMapper::containsUndefined($docBlockType);
}

return UndefinedTypeMapper::containsUndefined($phpdocType);
}

private function getParameterDescriptionFromDocBlock(DocBlock $docBlock, ReflectionParameter $parameter): string|null
{
/** @var DocBlock\Tags\Param[] $paramTags */
Expand Down Expand Up @@ -316,6 +336,9 @@ public function mapPropertyType(
/**
* Maps class property into input property.
*
* $acceptsNull says whether the constructor parameter or setter that receives the value accepts null.
* It defaults to the property's own type.
*
* @throws CannotMapTypeException
*/
public function mapInputProperty(
Expand All @@ -326,6 +349,7 @@ public function mapInputProperty(
mixed $defaultValue = null,
bool|null $isNullable = null,
bool $hasDefaultValue = false,
bool|null $acceptsNull = null,
): InputTypeProperty
{
$docBlockDescription = $docBlock->getSummary() . PHP_EOL . $docBlock->getDescription()->render();
Expand Down Expand Up @@ -362,14 +386,17 @@ public function mapInputProperty(
assert($inputType instanceof InputType);
}

if (! $hasDefaultValue && $isNullable) {
// Undefined before null: GraphQL would fill a printed `null` default in for an omitted `T|null|Undefined` field.
$containsUndefined = self::containsUndefined($phpdocType, $this->getDocBlockPropertyType($docBlock, $refProperty));

if (! $hasDefaultValue && $containsUndefined) {
$hasDefaultValue = true;
$defaultValue = null;
$defaultValue = Undefined::VALUE;
}

if (! $hasDefaultValue && UndefinedTypeMapper::containsUndefined($phpdocType)) {
if (! $hasDefaultValue && $isNullable) {
$hasDefaultValue = true;
$defaultValue = Undefined::VALUE;
$defaultValue = null;
}

$fieldName = $argumentName ?? $refProperty->getName();
Expand All @@ -385,6 +412,8 @@ public function mapInputProperty(
defaultValue: $defaultValue,
defaultValueImplicit: $defaultValue === Undefined::VALUE,
argumentResolver: $this->argumentResolver,
refusesNull: $containsUndefined && ! ($acceptsNull ?? $propertyType?->allowsNull() ?? true),
acceptsUndefined: $containsUndefined,
);
}

Expand Down
Loading
Loading