diff --git a/src/Utils/Value.php b/src/Utils/Value.php index 319594a6e..e0d082b04 100644 --- a/src/Utils/Value.php +++ b/src/Utils/Value.php @@ -182,33 +182,27 @@ public static function coerceInputValue($value, InputType $type, ?array $path = // Validate OneOf constraints if this is a OneOf input type if ($type->isOneOf()) { - $providedFieldCount = 0; + $providedFieldCount = count($coercedValue); $nullFieldName = null; - foreach ($coercedValue as $fieldName => $fieldValue) { - if ($fieldValue !== null) { - ++$providedFieldCount; - } else { - $nullFieldName = $fieldName; - } - } - - // Check for null field values first (takes precedence) - if ($nullFieldName !== null) { - $errors = self::add( - $errors, - CoercionError::make("OneOf input object \"{$type->name}\" field \"{$nullFieldName}\" must be non-null.", $path, $value) - ); - } elseif ($providedFieldCount === 0) { - $errors = self::add( - $errors, - CoercionError::make("OneOf input object \"{$type->name}\" must specify exactly one field.", $path, $value) - ); - } elseif ($providedFieldCount > 1) { + if ($providedFieldCount !== 1) { $errors = self::add( $errors, CoercionError::make("OneOf input object \"{$type->name}\" must specify exactly one field.", $path, $value) ); + } else { + foreach ($coercedValue as $fieldName => $fieldValue) { + if ($fieldValue === null) { + $nullFieldName = $fieldName; + } + } + + if ($nullFieldName !== null) { + $errors = self::add( + $errors, + CoercionError::make("OneOf input object \"{$type->name}\" field \"{$nullFieldName}\" must be non-null.", $path, $value) + ); + } } } diff --git a/tests/Type/OneOfInputObjectTest.php b/tests/Type/OneOfInputObjectTest.php index 2131d961e..8730cabec 100644 --- a/tests/Type/OneOfInputObjectTest.php +++ b/tests/Type/OneOfInputObjectTest.php @@ -250,4 +250,111 @@ public function testOneOfCoercionValidation(): void self::assertCount(1, $nullFieldResult['errors']); self::assertEquals('OneOf input object "OneOfInput" field "stringField" must be non-null.', $nullFieldResult['errors'][0]->getMessage()); } + + /** + * When a nullable @oneOf field is passed as null via variables, + * no @oneOf validation should trigger. + */ + public function testNullableOneOfFieldPassedAsNullVariable(): void + { + $schema = $this->buildOneOfSchema(); + + $result = GraphQL::executeQuery( + $schema, + '{ test(input: { oneOfField: null, name: "hello" }) }', + ); + + self::assertEmpty($result->errors, sprintf( + 'Expected no errors when passing null for a nullable @oneOf field, got: %s', + implode(', ', array_map(static fn ($e) => $e->getMessage(), $result->errors)), + )); + } + + /** + * When a nullable @oneOf field is omitted entirely, + * no @oneOf validation should trigger. + */ + public function testNullableOneOfFieldOmittedFromInput(): void + { + $schema = $this->buildOneOfSchema(); + + $result = GraphQL::executeQuery( + $schema, + '{ test(input: { name: "hello" }) }', + ); + + self::assertEmpty($result->errors, sprintf( + 'Expected no errors when omitting a nullable @oneOf field, got: %s', + implode(', ', array_map(static fn ($e) => $e->getMessage(), $result->errors)), + )); + } + + /** + * When both @oneOf fields are provided (one non-null, one null), the count + * check should take precedence over the null check, producing the more + * actionable "must specify exactly one field" error. + */ + public function testOneOfCoercionWithMultipleFieldsReportsCountErrorOverNullError(): void + { + $oneOfType = new InputObjectType([ + 'name' => 'OneOfInput', + 'fields' => [ + 'stringField' => Type::string(), + 'intField' => Type::int(), + ], + 'isOneOf' => true, + ]); + + $result = Value::coerceInputValue(['stringField' => 'test', 'intField' => null], $oneOfType); + self::assertNotNull($result['errors']); + self::assertCount(1, $result['errors']); + self::assertEquals('OneOf input object "OneOfInput" must specify exactly one field.', $result['errors'][0]->getMessage()); + } + + /** + * Builds a schema with a regular input type that contains a nullable @oneOf field. + * + * input TestInput { + * oneOfField: OneOfInput + * name: String! + * } + * input OneOfInput @oneOf { + * stringField: String + * intField: Int + * } + * + * @throws \GraphQL\Error\InvariantViolation + */ + private function buildOneOfSchema(): Schema + { + $oneOfInput = new InputObjectType([ + 'name' => 'OneOfInput', + 'isOneOf' => true, + 'fields' => [ + 'stringField' => Type::string(), + 'intField' => Type::int(), + ], + ]); + + $testInput = new InputObjectType([ + 'name' => 'TestInput', + 'fields' => [ + 'oneOfField' => $oneOfInput, + 'name' => Type::nonNull(Type::string()), + ], + ]); + + return new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'test' => [ + 'type' => Type::string(), + 'args' => ['input' => Type::nonNull($testInput)], + 'resolve' => static fn () => 'test', + ], + ], + ]), + ]); + } }