diff --git a/CHANGELOG.md b/CHANGELOG.md index dbf2bf061..fb7aa4f7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ You can find and compare releases at the [GitHub release page](https://github.co ## Unreleased +### Added + +- Add support for `@oneOf` input object directive - enables "input unions" where exactly one field must be provided https://github.com/webonyx/graphql-php/pull/1715 + ## v15.20.1 ### Fixed diff --git a/src/Type/Definition/Directive.php b/src/Type/Definition/Directive.php index e884cea73..9f3984527 100644 --- a/src/Type/Definition/Directive.php +++ b/src/Type/Definition/Directive.php @@ -26,6 +26,7 @@ class Directive public const SKIP_NAME = 'skip'; public const DEPRECATED_NAME = 'deprecated'; public const REASON_ARGUMENT_NAME = 'reason'; + public const ONE_OF_NAME = 'oneOf'; /** * Lazily initialized. @@ -81,6 +82,7 @@ public static function getInternalDirectives(): array self::INCLUDE_NAME => self::includeDirective(), self::SKIP_NAME => self::skipDirective(), self::DEPRECATED_NAME => self::deprecatedDirective(), + self::ONE_OF_NAME => self::oneOfDirective(), ]; } @@ -143,6 +145,18 @@ public static function deprecatedDirective(): Directive ]); } + public static function oneOfDirective(): Directive + { + return self::$internalDirectives[self::ONE_OF_NAME] ??= new self([ + 'name' => self::ONE_OF_NAME, + 'description' => 'Indicates that an input object is a oneof input object and exactly one of the input fields must be specified.', + 'locations' => [ + DirectiveLocation::INPUT_OBJECT, + ], + 'args' => [], + ]); + } + public static function isSpecifiedDirective(Directive $directive): bool { return array_key_exists($directive->name, self::getInternalDirectives()); diff --git a/src/Type/Definition/InputObjectType.php b/src/Type/Definition/InputObjectType.php index 3aac0770d..6f1e374c2 100644 --- a/src/Type/Definition/InputObjectType.php +++ b/src/Type/Definition/InputObjectType.php @@ -18,6 +18,7 @@ * @phpstan-type InputObjectConfig array{ * name?: string|null, * description?: string|null, + * isOneOf?: bool|null, * fields: iterable|callable(): iterable, * parseValue?: ParseValueFn|null, * astNode?: InputObjectTypeDefinitionNode|null, @@ -28,6 +29,8 @@ class InputObjectType extends Type implements InputType, NullableType, NamedType { use NamedTypeImplementation; + public bool $isOneOf; + /** * Lazily initialized. * @@ -57,6 +60,7 @@ public function __construct(array $config) { $this->name = $config['name'] ?? $this->inferName(); $this->description = $config['description'] ?? null; + $this->isOneOf = $config['isOneOf'] ?? false; // $this->fields is initialized lazily $this->parseValue = $config['parseValue'] ?? null; $this->astNode = $config['astNode'] ?? null; @@ -97,6 +101,12 @@ public function hasField(string $name): bool return isset($this->fields[$name]); } + /** Returns true if this is a oneOf input object type. */ + public function isOneOf(): bool + { + return $this->isOneOf; + } + /** * @throws InvariantViolation * @@ -202,6 +212,39 @@ public function assertValid(): void foreach ($resolvedFields as $field) { $field->assertValid($this); } + + // Additional validation for oneOf input objects + if ($this->isOneOf()) { + $this->validateOneOfConstraints($resolvedFields); + } + } + + /** + * Validates that oneOf input object constraints are met. + * + * @param array $fields + * + * @throws InvariantViolation + */ + private function validateOneOfConstraints(array $fields): void + { + if (count($fields) === 0) { + throw new InvariantViolation("OneOf input object type {$this->name} must define one or more fields."); + } + + foreach ($fields as $fieldName => $field) { + $fieldType = $field->getType(); + + // OneOf fields must be nullable (not wrapped in NonNull) + if ($fieldType instanceof NonNull) { + throw new InvariantViolation("OneOf input object type {$this->name} field {$fieldName} must be nullable."); + } + + // OneOf fields cannot have default values + if ($field->defaultValueExists()) { + throw new InvariantViolation("OneOf input object type {$this->name} field {$fieldName} cannot have a default value."); + } + } } public function astNode(): ?InputObjectTypeDefinitionNode diff --git a/src/Type/Introspection.php b/src/Type/Introspection.php index b1a831178..cf26e0758 100644 --- a/src/Type/Introspection.php +++ b/src/Type/Introspection.php @@ -431,6 +431,12 @@ public static function _type(): ObjectType ? $type->getWrappedType() : null, ], + 'isOneOf' => [ + 'type' => Type::boolean(), + 'resolve' => static fn ($type): ?bool => $type instanceof InputObjectType + ? $type->isOneOf() + : null, + ], ], ]); } diff --git a/src/Utils/BuildSchema.php b/src/Utils/BuildSchema.php index 83d4d1961..349a25d14 100644 --- a/src/Utils/BuildSchema.php +++ b/src/Utils/BuildSchema.php @@ -238,6 +238,9 @@ static function (string $typeName): Type { if (! isset($directivesByName['deprecated'])) { $directives[] = Directive::deprecatedDirective(); } + if (! isset($directivesByName['oneOf'])) { + $directives[] = Directive::oneOfDirective(); + } // Note: While this could make early assertions to get the correctly // typed values below, that would throw immediately while type system diff --git a/src/Utils/Value.php b/src/Utils/Value.php index 6dd4713ad..d2cdcccf1 100644 --- a/src/Utils/Value.php +++ b/src/Utils/Value.php @@ -171,6 +171,38 @@ public static function coerceInputValue($value, InputType $type, ?array $path = ); } + // Validate OneOf constraints if this is a OneOf input type + if ($type->isOneOf()) { + $providedFieldCount = 0; + $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) { + $errors = self::add( + $errors, + CoercionError::make("OneOf input object \"{$type->name}\" must specify exactly one field.", $path, $value) + ); + } + } + return $errors === [] ? self::ofValue($type->parseValue($coercedValue)) : self::ofErrors($errors); diff --git a/src/Validator/DocumentValidator.php b/src/Validator/DocumentValidator.php index 08c4068a3..0c4eb498f 100644 --- a/src/Validator/DocumentValidator.php +++ b/src/Validator/DocumentValidator.php @@ -22,6 +22,7 @@ use GraphQL\Validator\Rules\NoUndefinedVariables; use GraphQL\Validator\Rules\NoUnusedFragments; use GraphQL\Validator\Rules\NoUnusedVariables; +use GraphQL\Validator\Rules\OneOfInputObjectsRule; use GraphQL\Validator\Rules\OverlappingFieldsCanBeMerged; use GraphQL\Validator\Rules\PossibleFragmentSpreads; use GraphQL\Validator\Rules\PossibleTypeExtensions; @@ -179,6 +180,7 @@ public static function defaultRules(): array VariablesInAllowedPosition::class => new VariablesInAllowedPosition(), OverlappingFieldsCanBeMerged::class => new OverlappingFieldsCanBeMerged(), UniqueInputFieldNames::class => new UniqueInputFieldNames(), + OneOfInputObjectsRule::class => new OneOfInputObjectsRule(), ]; } diff --git a/src/Validator/Rules/OneOfInputObjectsRule.php b/src/Validator/Rules/OneOfInputObjectsRule.php new file mode 100644 index 000000000..f81248060 --- /dev/null +++ b/src/Validator/Rules/OneOfInputObjectsRule.php @@ -0,0 +1,94 @@ + static function (ObjectValueNode $node) use ($context): void { + $type = $context->getInputType(); + + if ($type === null) { + return; + } + + $namedType = Type::getNamedType($type); + if (! ($namedType instanceof InputObjectType) + || ! $namedType->isOneOf() + ) { + return; + } + + $providedFields = []; + $nullFields = []; + + foreach ($node->fields as $fieldNode) { + $fieldName = $fieldNode->name->value; + $providedFields[] = $fieldName; + + // Check if the field value is explicitly null + if ($fieldNode->value->kind === NodeKind::NULL) { + $nullFields[] = $fieldName; + } + } + + $fieldCount = count($providedFields); + + if ($fieldCount === 0) { + $context->reportError(new Error( + static::oneOfInputObjectExpectedExactlyOneFieldMessage($namedType->name), + [$node] + )); + + return; + } + + if ($fieldCount > 1) { + $context->reportError(new Error( + static::oneOfInputObjectExpectedExactlyOneFieldMessage($namedType->name, $fieldCount), + [$node] + )); + + return; + } + + // At this point, $fieldCount === 1 + if (count($nullFields) > 0) { + // Exactly one field provided, but it's null + $context->reportError(new Error( + static::oneOfInputObjectFieldValueMustNotBeNullMessage($namedType->name, $nullFields[0]), + [$node] + )); + } + }, + ]; + } + + public static function oneOfInputObjectExpectedExactlyOneFieldMessage(string $typeName, ?int $providedCount = null): string + { + if ($providedCount === null) { + return "OneOf input object '{$typeName}' must specify exactly one field."; + } + + return "OneOf input object '{$typeName}' must specify exactly one field, but {$providedCount} fields were provided."; + } + + public static function oneOfInputObjectFieldValueMustNotBeNullMessage(string $typeName, string $fieldName): string + { + return "OneOf input object '{$typeName}' field '{$fieldName}' must be non-null."; + } +} diff --git a/tests/Type/IntrospectionTest.php b/tests/Type/IntrospectionTest.php index c484db3a5..88ce63c35 100644 --- a/tests/Type/IntrospectionTest.php +++ b/tests/Type/IntrospectionTest.php @@ -365,6 +365,17 @@ public function testExecutesAnIntrospectionQuery(): void 'isDeprecated' => false, 'deprecationReason' => null, ], + 9 => [ + 'name' => 'isOneOf', + 'args' => [], + 'type' => [ + 'kind' => 'SCALAR', + 'name' => 'Boolean', + 'ofType' => null, + ], + 'isDeprecated' => false, + 'deprecationReason' => null, + ], ], 'inputFields' => null, 'interfaces' => [], @@ -962,6 +973,14 @@ public function testExecutesAnIntrospectionQuery(): void 3 => 'INPUT_FIELD_DEFINITION', ], ], + [ + 'name' => 'oneOf', + 'args' => [], + 'isRepeatable' => false, + 'locations' => [ + 0 => 'INPUT_OBJECT', + ], + ], ], ], ], diff --git a/tests/Type/OneOfInputObjectTest.php b/tests/Type/OneOfInputObjectTest.php new file mode 100644 index 000000000..2110bedbc --- /dev/null +++ b/tests/Type/OneOfInputObjectTest.php @@ -0,0 +1,251 @@ + 'OneOfInput', + 'isOneOf' => true, + 'fields' => [ + 'stringField' => Type::string(), + 'intField' => Type::int(), + ], + ]); + + self::assertTrue($oneOfInput->isOneOf()); + self::assertCount(2, $oneOfInput->getFields()); + } + + public function testOneOfInputObjectValidation(): void + { + $oneOfInput = new InputObjectType([ + 'name' => 'OneOfInput', + 'isOneOf' => true, + 'fields' => [ + 'stringField' => Type::string(), + 'intField' => Type::int(), + ], + ]); + + // Should not throw for valid oneOf input + $oneOfInput->assertValid(); + $this->assertDidNotCrash(); + } + + public function testOneOfInputObjectRejectsNonNullFields(): void + { + $oneOfInput = new InputObjectType([ + 'name' => 'OneOfInput', + 'isOneOf' => true, + 'fields' => [ + 'stringField' => Type::nonNull(Type::string()), // This should fail + 'intField' => Type::int(), + ], + ]); + + $this->expectException(InvariantViolation::class); + $this->expectExceptionMessage('OneOf input object type OneOfInput field stringField must be nullable'); + + $oneOfInput->assertValid(); + } + + public function testOneOfInputObjectRejectsDefaultValues(): void + { + $oneOfInput = new InputObjectType([ + 'name' => 'OneOfInput', + 'isOneOf' => true, + 'fields' => [ + 'stringField' => [ + 'type' => Type::string(), + 'defaultValue' => 'default', // This should fail + ], + 'intField' => Type::int(), + ], + ]); + + $this->expectException(InvariantViolation::class); + $this->expectExceptionMessage('OneOf input object type OneOfInput field stringField cannot have a default value'); + + $oneOfInput->assertValid(); + } + + public function testOneOfInputObjectRequiresAtLeastOneField(): void + { + $oneOfInput = new InputObjectType([ + 'name' => 'OneOfInput', + 'isOneOf' => true, + 'fields' => [], // Empty fields array should fail + ]); + + $this->expectException(InvariantViolation::class); + $this->expectExceptionMessage('OneOf input object type OneOfInput must define one or more fields'); + + $oneOfInput->assertValid(); + } + + public function testOneOfInputObjectSchemaValidation(): void + { + $oneOfInput = new InputObjectType([ + 'name' => 'OneOfInput', + 'isOneOf' => true, + 'fields' => [ + 'stringField' => Type::string(), + 'intField' => Type::int(), + ], + ]); + + $query = new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'test' => [ + 'type' => Type::string(), + 'args' => [ + 'input' => $oneOfInput, + ], + 'resolve' => static fn () => 'test', + ], + ], + ]); + + $schema = new Schema(['query' => $query]); + + // Valid query with exactly one field + $validQuery = '{ test(input: { stringField: "hello" }) }'; + $result = GraphQL::executeQuery($schema, $validQuery); + self::assertEmpty($result->errors); + + // Invalid query with multiple fields + $invalidQuery = '{ test(input: { stringField: "hello", intField: 42 }) }'; + $result = GraphQL::executeQuery($schema, $invalidQuery); + self::assertNotEmpty($result->errors); + self::assertCount(1, $result->errors); + self::assertStringContainsString('must specify exactly one field', $result->errors[0]->getMessage()); + + // Invalid query with no fields + $emptyQuery = '{ test(input: {}) }'; + $result = GraphQL::executeQuery($schema, $emptyQuery); + self::assertNotEmpty($result->errors); + self::assertCount(1, $result->errors); + self::assertStringContainsString('must specify exactly one field', $result->errors[0]->getMessage()); + + // Invalid query with null field value + $nullQuery = '{ test(input: { stringField: null }) }'; + $result = GraphQL::executeQuery($schema, $nullQuery); + self::assertNotEmpty($result->errors); + self::assertCount(1, $result->errors); + self::assertStringContainsString('must be non-null', $result->errors[0]->getMessage()); + } + + public function testOneOfIntrospection(): void + { + $oneOfInput = new InputObjectType([ + 'name' => 'OneOfInput', + 'isOneOf' => true, + 'fields' => [ + 'stringField' => Type::string(), + 'intField' => Type::int(), + ], + ]); + + $regularInput = new InputObjectType([ + 'name' => 'RegularInput', + 'fields' => [ + 'stringField' => Type::string(), + 'intField' => Type::int(), + ], + ]); + + $query = new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'test' => [ + 'type' => Type::string(), + 'resolve' => static fn () => 'test', + ], + ], + ]); + + $schema = new Schema([ + 'query' => $query, + 'types' => [$oneOfInput, $regularInput], + ]); + + $introspectionQuery = ' + { + __schema { + types { + name + isOneOf + } + } + } + '; + + $result = GraphQL::executeQuery($schema, $introspectionQuery); + self::assertEmpty($result->errors); + + $types = $result->data['__schema']['types'] ?? []; + $oneOfType = null; + $regularType = null; + + foreach ($types as $type) { + if ($type['name'] === 'OneOfInput') { + $oneOfType = $type; + } elseif ($type['name'] === 'RegularInput') { + $regularType = $type; + } + } + + self::assertNotNull($oneOfType); + self::assertNotNull($regularType); + self::assertTrue($oneOfType['isOneOf']); + self::assertFalse($regularType['isOneOf']); // Should be false for regular input objects + } + + public function testOneOfCoercionValidation(): void + { + $oneOfType = new InputObjectType([ + 'name' => 'OneOfInput', + 'fields' => [ + 'stringField' => Type::string(), + 'intField' => Type::int(), + ], + 'isOneOf' => true, + ]); + + // Test valid input (exactly one field) + $validResult = Value::coerceInputValue(['stringField' => 'test'], $oneOfType); + self::assertNull($validResult['errors']); + self::assertEquals(['stringField' => 'test'], $validResult['value']); + + // Test invalid input (no fields) + $noFieldsResult = Value::coerceInputValue([], $oneOfType); + self::assertNotNull($noFieldsResult['errors']); + self::assertCount(1, $noFieldsResult['errors']); + self::assertEquals('OneOf input object "OneOfInput" must specify exactly one field.', $noFieldsResult['errors'][0]->getMessage()); + + // Test invalid input (multiple fields) + $multipleFieldsResult = Value::coerceInputValue(['stringField' => 'test', 'intField' => 42], $oneOfType); + self::assertNotNull($multipleFieldsResult['errors']); + self::assertCount(1, $multipleFieldsResult['errors']); + self::assertEquals('OneOf input object "OneOfInput" must specify exactly one field.', $multipleFieldsResult['errors'][0]->getMessage()); + + // Test invalid input (null field value) + $nullFieldResult = Value::coerceInputValue(['stringField' => null], $oneOfType); + self::assertNotNull($nullFieldResult['errors']); + self::assertCount(1, $nullFieldResult['errors']); + self::assertEquals('OneOf input object "OneOfInput" field "stringField" must be non-null.', $nullFieldResult['errors'][0]->getMessage()); + } +} diff --git a/tests/Utils/BreakingChangesFinderTest.php b/tests/Utils/BreakingChangesFinderTest.php index b9c365063..2f4a6c760 100644 --- a/tests/Utils/BreakingChangesFinderTest.php +++ b/tests/Utils/BreakingChangesFinderTest.php @@ -1333,6 +1333,7 @@ public function testShouldDetectIfADirectiveWasImplicitlyRemoved(): void ]); $deprecatedDirective = Directive::deprecatedDirective(); + $oneOfDirective = Directive::oneOfDirective(); self::assertEquals( [ @@ -1340,6 +1341,10 @@ public function testShouldDetectIfADirectiveWasImplicitlyRemoved(): void 'type' => BreakingChangesFinder::BREAKING_CHANGE_DIRECTIVE_REMOVED, 'description' => "{$deprecatedDirective->name} was removed", ], + [ + 'type' => BreakingChangesFinder::BREAKING_CHANGE_DIRECTIVE_REMOVED, + 'description' => "{$oneOfDirective->name} was removed", + ], ], BreakingChangesFinder::findRemovedDirectives($oldSchema, $newSchema) ); diff --git a/tests/Utils/BuildSchemaTest.php b/tests/Utils/BuildSchemaTest.php index b222f88b5..11cd8c2d8 100644 --- a/tests/Utils/BuildSchemaTest.php +++ b/tests/Utils/BuildSchemaTest.php @@ -272,11 +272,12 @@ public function testMaintainsIncludeSkipAndSpecifiedBy(): void { $schema = BuildSchema::buildAST(Parser::parse('type Query')); - // TODO switch to 4 when adding @specifiedBy - see https://github.com/webonyx/graphql-php/issues/1140 - self::assertCount(3, $schema->getDirectives()); + // TODO switch to 5 when adding @specifiedBy - see https://github.com/webonyx/graphql-php/issues/1140 + self::assertCount(4, $schema->getDirectives()); self::assertSame(Directive::skipDirective(), $schema->getDirective('skip')); self::assertSame(Directive::includeDirective(), $schema->getDirective('include')); self::assertSame(Directive::deprecatedDirective(), $schema->getDirective('deprecated')); + self::assertSame(Directive::oneOfDirective(), $schema->getDirective('oneOf')); self::markTestIncomplete('See https://github.com/webonyx/graphql-php/issues/1140'); self::assertSame(Directive::specifiedByDirective(), $schema->getDirective('specifiedBy')); @@ -292,10 +293,11 @@ public function testOverridingDirectivesExcludesSpecified(): void directive @specifiedBy on FIELD_DEFINITION ')); - self::assertCount(4, $schema->getDirectives()); + self::assertCount(5, $schema->getDirectives()); self::assertNotEquals(Directive::skipDirective(), $schema->getDirective('skip')); self::assertNotEquals(Directive::includeDirective(), $schema->getDirective('include')); self::assertNotEquals(Directive::deprecatedDirective(), $schema->getDirective('deprecated')); + self::assertSame(Directive::oneOfDirective(), $schema->getDirective('oneOf')); self::markTestIncomplete('See https://github.com/webonyx/graphql-php/issues/1140'); self::assertNotEquals(Directive::specifiedByDirective(), $schema->getDirective('specifiedBy')); @@ -310,12 +312,13 @@ public function testAddingDirectivesMaintainsIncludeSkipAndSpecifiedBy(): void GRAPHQL; $schema = BuildSchema::buildAST(Parser::parse($sdl)); - // TODO switch to 5 when adding @specifiedBy - see https://github.com/webonyx/graphql-php/issues/1140 - self::assertCount(4, $schema->getDirectives()); + // TODO switch to 6 when adding @specifiedBy - see https://github.com/webonyx/graphql-php/issues/1140 + self::assertCount(5, $schema->getDirectives()); self::assertNotNull($schema->getDirective('foo')); self::assertNotNull($schema->getDirective('skip')); self::assertNotNull($schema->getDirective('include')); self::assertNotNull($schema->getDirective('deprecated')); + self::assertNotNull($schema->getDirective('oneOf')); self::markTestIncomplete('See https://github.com/webonyx/graphql-php/issues/1140'); self::assertNotNull($schema->getDirective('specifiedBy')); diff --git a/tests/Utils/SchemaPrinterTest.php b/tests/Utils/SchemaPrinterTest.php index 797157539..a5f753c04 100644 --- a/tests/Utils/SchemaPrinterTest.php +++ b/tests/Utils/SchemaPrinterTest.php @@ -1011,6 +1011,9 @@ public function testPrintIntrospectionSchema(): void reason: String = "No longer supported" ) on FIELD_DEFINITION | ENUM_VALUE | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION + "Indicates that an input object is a oneof input object and exactly one of the input fields must be specified." + directive @oneOf on INPUT_OBJECT + "A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations." type __Schema { "A list of all types supported by this server." @@ -1044,6 +1047,7 @@ interfaces: [__Type!] enumValues(includeDeprecated: Boolean = false): [__EnumValue!] inputFields(includeDeprecated: Boolean = false): [__InputValue!] ofType: __Type + isOneOf: Boolean } "An enum describing what kind of type a given `__Type` is."