Skip to content

feat: Add Support for @oneOf Input Object Directive #1715

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jul 8, 2025
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/Type/Definition/Directive.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(),
];
}

Expand Down Expand Up @@ -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());
Expand Down
43 changes: 43 additions & 0 deletions src/Type/Definition/InputObjectType.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
* @phpstan-type InputObjectConfig array{
* name?: string|null,
* description?: string|null,
* isOneOf?: bool|null,
* fields: iterable<FieldConfig>|callable(): iterable<FieldConfig>,
* parseValue?: ParseValueFn|null,
* astNode?: InputObjectTypeDefinitionNode|null,
Expand All @@ -28,6 +29,8 @@ class InputObjectType extends Type implements InputType, NullableType, NamedType
{
use NamedTypeImplementation;

public bool $isOneOf;

/**
* Lazily initialized.
*
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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<string, InputObjectField> $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
Expand Down
6 changes: 6 additions & 0 deletions src/Type/Introspection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
],
],
]);
}
Expand Down
3 changes: 3 additions & 0 deletions src/Utils/BuildSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions src/Utils/Value.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/Validator/DocumentValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
];
}

Expand Down
94 changes: 94 additions & 0 deletions src/Validator/Rules/OneOfInputObjectsRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php declare(strict_types=1);

namespace GraphQL\Validator\Rules;

use GraphQL\Error\Error;
use GraphQL\Language\AST\NodeKind;
use GraphQL\Language\AST\ObjectValueNode;
use GraphQL\Type\Definition\InputObjectType;
use GraphQL\Type\Definition\Type;
use GraphQL\Validator\QueryValidationContext;

/**
* OneOf Input Objects validation rule.
*
* Validates that OneOf Input Objects have exactly one non-null field provided.
*/
class OneOfInputObjectsRule extends ValidationRule
{
public function getVisitor(QueryValidationContext $context): array
{
return [
NodeKind::OBJECT => static function (ObjectValueNode $node) use ($context): void {
$type = $context->getInputType();

if ($type === null) {
return;

Check warning on line 26 in src/Validator/Rules/OneOfInputObjectsRule.php

View check run for this annotation

Codecov / codecov/patch

src/Validator/Rules/OneOfInputObjectsRule.php#L26

Added line #L26 was not covered by tests
}

$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.";
}
}
19 changes: 19 additions & 0 deletions tests/Type/IntrospectionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [],
Expand Down Expand Up @@ -962,6 +973,14 @@ public function testExecutesAnIntrospectionQuery(): void
3 => 'INPUT_FIELD_DEFINITION',
],
],
[
'name' => 'oneOf',
'args' => [],
'isRepeatable' => false,
'locations' => [
0 => 'INPUT_OBJECT',
],
],
],
],
],
Expand Down
Loading
Loading