Skip to content

Commit f41ed56

Browse files
authored
feat: Add Support for @oneOf Input Object Directive (#1715)
1 parent 424a5f2 commit f41ed56

13 files changed

+485
-5
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ You can find and compare releases at the [GitHub release page](https://github.co
99

1010
## Unreleased
1111

12+
### Added
13+
14+
- 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
15+
1216
## v15.20.1
1317

1418
### Fixed

src/Type/Definition/Directive.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class Directive
2626
public const SKIP_NAME = 'skip';
2727
public const DEPRECATED_NAME = 'deprecated';
2828
public const REASON_ARGUMENT_NAME = 'reason';
29+
public const ONE_OF_NAME = 'oneOf';
2930

3031
/**
3132
* Lazily initialized.
@@ -81,6 +82,7 @@ public static function getInternalDirectives(): array
8182
self::INCLUDE_NAME => self::includeDirective(),
8283
self::SKIP_NAME => self::skipDirective(),
8384
self::DEPRECATED_NAME => self::deprecatedDirective(),
85+
self::ONE_OF_NAME => self::oneOfDirective(),
8486
];
8587
}
8688

@@ -143,6 +145,18 @@ public static function deprecatedDirective(): Directive
143145
]);
144146
}
145147

148+
public static function oneOfDirective(): Directive
149+
{
150+
return self::$internalDirectives[self::ONE_OF_NAME] ??= new self([
151+
'name' => self::ONE_OF_NAME,
152+
'description' => 'Indicates that an input object is a oneof input object and exactly one of the input fields must be specified.',
153+
'locations' => [
154+
DirectiveLocation::INPUT_OBJECT,
155+
],
156+
'args' => [],
157+
]);
158+
}
159+
146160
public static function isSpecifiedDirective(Directive $directive): bool
147161
{
148162
return array_key_exists($directive->name, self::getInternalDirectives());

src/Type/Definition/InputObjectType.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
* @phpstan-type InputObjectConfig array{
1919
* name?: string|null,
2020
* description?: string|null,
21+
* isOneOf?: bool|null,
2122
* fields: iterable<FieldConfig>|callable(): iterable<FieldConfig>,
2223
* parseValue?: ParseValueFn|null,
2324
* astNode?: InputObjectTypeDefinitionNode|null,
@@ -28,6 +29,8 @@ class InputObjectType extends Type implements InputType, NullableType, NamedType
2829
{
2930
use NamedTypeImplementation;
3031

32+
public bool $isOneOf;
33+
3134
/**
3235
* Lazily initialized.
3336
*
@@ -56,6 +59,7 @@ public function __construct(array $config)
5659
{
5760
$this->name = $config['name'] ?? $this->inferName();
5861
$this->description = $config['description'] ?? null;
62+
$this->isOneOf = $config['isOneOf'] ?? false;
5963
// $this->fields is initialized lazily
6064
$this->parseValue = $config['parseValue'] ?? null;
6165
$this->astNode = $config['astNode'] ?? null;
@@ -96,6 +100,12 @@ public function hasField(string $name): bool
96100
return isset($this->fields[$name]);
97101
}
98102

103+
/** Returns true if this is a oneOf input object type. */
104+
public function isOneOf(): bool
105+
{
106+
return $this->isOneOf;
107+
}
108+
99109
/**
100110
* @throws InvariantViolation
101111
*
@@ -201,6 +211,39 @@ public function assertValid(): void
201211
foreach ($resolvedFields as $field) {
202212
$field->assertValid($this);
203213
}
214+
215+
// Additional validation for oneOf input objects
216+
if ($this->isOneOf()) {
217+
$this->validateOneOfConstraints($resolvedFields);
218+
}
219+
}
220+
221+
/**
222+
* Validates that oneOf input object constraints are met.
223+
*
224+
* @param array<string, InputObjectField> $fields
225+
*
226+
* @throws InvariantViolation
227+
*/
228+
private function validateOneOfConstraints(array $fields): void
229+
{
230+
if (count($fields) === 0) {
231+
throw new InvariantViolation("OneOf input object type {$this->name} must define one or more fields.");
232+
}
233+
234+
foreach ($fields as $fieldName => $field) {
235+
$fieldType = $field->getType();
236+
237+
// OneOf fields must be nullable (not wrapped in NonNull)
238+
if ($fieldType instanceof NonNull) {
239+
throw new InvariantViolation("OneOf input object type {$this->name} field {$fieldName} must be nullable.");
240+
}
241+
242+
// OneOf fields cannot have default values
243+
if ($field->defaultValueExists()) {
244+
throw new InvariantViolation("OneOf input object type {$this->name} field {$fieldName} cannot have a default value.");
245+
}
246+
}
204247
}
205248

206249
public function astNode(): ?InputObjectTypeDefinitionNode

src/Type/Introspection.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,12 @@ public static function _type(): ObjectType
431431
? $type->getWrappedType()
432432
: null,
433433
],
434+
'isOneOf' => [
435+
'type' => Type::boolean(),
436+
'resolve' => static fn ($type): ?bool => $type instanceof InputObjectType
437+
? $type->isOneOf()
438+
: null,
439+
],
434440
],
435441
]);
436442
}

src/Utils/BuildSchema.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,9 @@ static function (string $typeName): Type {
235235
if (! isset($directivesByName['deprecated'])) {
236236
$directives[] = Directive::deprecatedDirective();
237237
}
238+
if (! isset($directivesByName['oneOf'])) {
239+
$directives[] = Directive::oneOfDirective();
240+
}
238241

239242
// Note: While this could make early assertions to get the correctly
240243
// typed values below, that would throw immediately while type system

src/Utils/Value.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,38 @@ public static function coerceInputValue($value, InputType $type, ?array $path =
171171
);
172172
}
173173

174+
// Validate OneOf constraints if this is a OneOf input type
175+
if ($type->isOneOf()) {
176+
$providedFieldCount = 0;
177+
$nullFieldName = null;
178+
179+
foreach ($coercedValue as $fieldName => $fieldValue) {
180+
if ($fieldValue !== null) {
181+
++$providedFieldCount;
182+
} else {
183+
$nullFieldName = $fieldName;
184+
}
185+
}
186+
187+
// Check for null field values first (takes precedence)
188+
if ($nullFieldName !== null) {
189+
$errors = self::add(
190+
$errors,
191+
CoercionError::make("OneOf input object \"{$type->name}\" field \"{$nullFieldName}\" must be non-null.", $path, $value)
192+
);
193+
} elseif ($providedFieldCount === 0) {
194+
$errors = self::add(
195+
$errors,
196+
CoercionError::make("OneOf input object \"{$type->name}\" must specify exactly one field.", $path, $value)
197+
);
198+
} elseif ($providedFieldCount > 1) {
199+
$errors = self::add(
200+
$errors,
201+
CoercionError::make("OneOf input object \"{$type->name}\" must specify exactly one field.", $path, $value)
202+
);
203+
}
204+
}
205+
174206
return $errors === []
175207
? self::ofValue($type->parseValue($coercedValue))
176208
: self::ofErrors($errors);

src/Validator/DocumentValidator.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use GraphQL\Validator\Rules\NoUndefinedVariables;
2323
use GraphQL\Validator\Rules\NoUnusedFragments;
2424
use GraphQL\Validator\Rules\NoUnusedVariables;
25+
use GraphQL\Validator\Rules\OneOfInputObjectsRule;
2526
use GraphQL\Validator\Rules\OverlappingFieldsCanBeMerged;
2627
use GraphQL\Validator\Rules\PossibleFragmentSpreads;
2728
use GraphQL\Validator\Rules\PossibleTypeExtensions;
@@ -179,6 +180,7 @@ public static function defaultRules(): array
179180
VariablesInAllowedPosition::class => new VariablesInAllowedPosition(),
180181
OverlappingFieldsCanBeMerged::class => new OverlappingFieldsCanBeMerged(),
181182
UniqueInputFieldNames::class => new UniqueInputFieldNames(),
183+
OneOfInputObjectsRule::class => new OneOfInputObjectsRule(),
182184
];
183185
}
184186

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace GraphQL\Validator\Rules;
4+
5+
use GraphQL\Error\Error;
6+
use GraphQL\Language\AST\NodeKind;
7+
use GraphQL\Language\AST\ObjectValueNode;
8+
use GraphQL\Type\Definition\InputObjectType;
9+
use GraphQL\Type\Definition\Type;
10+
use GraphQL\Validator\QueryValidationContext;
11+
12+
/**
13+
* OneOf Input Objects validation rule.
14+
*
15+
* Validates that OneOf Input Objects have exactly one non-null field provided.
16+
*/
17+
class OneOfInputObjectsRule extends ValidationRule
18+
{
19+
public function getVisitor(QueryValidationContext $context): array
20+
{
21+
return [
22+
NodeKind::OBJECT => static function (ObjectValueNode $node) use ($context): void {
23+
$type = $context->getInputType();
24+
25+
if ($type === null) {
26+
return;
27+
}
28+
29+
$namedType = Type::getNamedType($type);
30+
if (! ($namedType instanceof InputObjectType)
31+
|| ! $namedType->isOneOf()
32+
) {
33+
return;
34+
}
35+
36+
$providedFields = [];
37+
$nullFields = [];
38+
39+
foreach ($node->fields as $fieldNode) {
40+
$fieldName = $fieldNode->name->value;
41+
$providedFields[] = $fieldName;
42+
43+
// Check if the field value is explicitly null
44+
if ($fieldNode->value->kind === NodeKind::NULL) {
45+
$nullFields[] = $fieldName;
46+
}
47+
}
48+
49+
$fieldCount = count($providedFields);
50+
51+
if ($fieldCount === 0) {
52+
$context->reportError(new Error(
53+
static::oneOfInputObjectExpectedExactlyOneFieldMessage($namedType->name),
54+
[$node]
55+
));
56+
57+
return;
58+
}
59+
60+
if ($fieldCount > 1) {
61+
$context->reportError(new Error(
62+
static::oneOfInputObjectExpectedExactlyOneFieldMessage($namedType->name, $fieldCount),
63+
[$node]
64+
));
65+
66+
return;
67+
}
68+
69+
// At this point, $fieldCount === 1
70+
if (count($nullFields) > 0) {
71+
// Exactly one field provided, but it's null
72+
$context->reportError(new Error(
73+
static::oneOfInputObjectFieldValueMustNotBeNullMessage($namedType->name, $nullFields[0]),
74+
[$node]
75+
));
76+
}
77+
},
78+
];
79+
}
80+
81+
public static function oneOfInputObjectExpectedExactlyOneFieldMessage(string $typeName, ?int $providedCount = null): string
82+
{
83+
if ($providedCount === null) {
84+
return "OneOf input object '{$typeName}' must specify exactly one field.";
85+
}
86+
87+
return "OneOf input object '{$typeName}' must specify exactly one field, but {$providedCount} fields were provided.";
88+
}
89+
90+
public static function oneOfInputObjectFieldValueMustNotBeNullMessage(string $typeName, string $fieldName): string
91+
{
92+
return "OneOf input object '{$typeName}' field '{$fieldName}' must be non-null.";
93+
}
94+
}

tests/Type/IntrospectionTest.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,17 @@ public function testExecutesAnIntrospectionQuery(): void
365365
'isDeprecated' => false,
366366
'deprecationReason' => null,
367367
],
368+
9 => [
369+
'name' => 'isOneOf',
370+
'args' => [],
371+
'type' => [
372+
'kind' => 'SCALAR',
373+
'name' => 'Boolean',
374+
'ofType' => null,
375+
],
376+
'isDeprecated' => false,
377+
'deprecationReason' => null,
378+
],
368379
],
369380
'inputFields' => null,
370381
'interfaces' => [],
@@ -962,6 +973,14 @@ public function testExecutesAnIntrospectionQuery(): void
962973
3 => 'INPUT_FIELD_DEFINITION',
963974
],
964975
],
976+
[
977+
'name' => 'oneOf',
978+
'args' => [],
979+
'isRepeatable' => false,
980+
'locations' => [
981+
0 => 'INPUT_OBJECT',
982+
],
983+
],
965984
],
966985
],
967986
],

0 commit comments

Comments
 (0)