Skip to content

Commit 982c3aa

Browse files
committed
- update Introspection to include isOneOf field
- add oneOfDirective definition, allowed on INPUT_OBJECT - add isOneOf to InputObjectType config - update BuldSchema to account for oneOf directive - add OneOfInputObjectsRule to validation rules
1 parent 1235a0d commit 982c3aa

File tree

6 files changed

+139
-0
lines changed

6 files changed

+139
-0
lines changed

src/Type/Definition/Directive.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class Directive
2727
public const SKIP_NAME = 'skip';
2828
public const DEPRECATED_NAME = 'deprecated';
2929
public const REASON_ARGUMENT_NAME = 'reason';
30+
public const ONE_OF_NAME = 'oneOf';
3031

3132
/**
3233
* Lazily initialized.
@@ -86,6 +87,7 @@ public static function getInternalDirectives(): array
8687
self::INCLUDE_NAME => self::includeDirective(),
8788
self::SKIP_NAME => self::skipDirective(),
8889
self::DEPRECATED_NAME => self::deprecatedDirective(),
90+
self::ONE_OF_NAME => self::oneOfDirective(),
8991
];
9092
}
9193

@@ -151,6 +153,19 @@ public static function deprecatedDirective(): Directive
151153
]);
152154
}
153155

156+
/** @throws InvariantViolation */
157+
public static function oneOfDirective(): Directive
158+
{
159+
return self::$internalDirectives[self::ONE_OF_NAME] ??= new self([
160+
'name' => self::ONE_OF_NAME,
161+
'description' => 'Indicates that an input object is a oneof input object and exactly one of the input fields must be specified.',
162+
'locations' => [
163+
DirectiveLocation::INPUT_OBJECT,
164+
],
165+
'args' => [],
166+
]);
167+
}
168+
154169
/** @throws InvariantViolation */
155170
public static function isSpecifiedDirective(Directive $directive): bool
156171
{

src/Type/Definition/InputObjectType.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
* name?: string|null,
1919
* description?: string|null,
2020
* fields: iterable<FieldConfig>|callable(): iterable<FieldConfig>,
21+
* isOneOf?: bool|null,
2122
* parseValue?: callable(array<string, mixed>): mixed,
2223
* astNode?: InputObjectTypeDefinitionNode|null,
2324
* extensionASTNodes?: array<InputObjectTypeExtensionNode>|null
@@ -35,6 +36,8 @@ class InputObjectType extends Type implements InputType, NullableType, NamedType
3536
/** @phpstan-var InputObjectConfig */
3637
public array $config;
3738

39+
public bool $isOneOf;
40+
3841
/**
3942
* Lazily initialized.
4043
*
@@ -55,6 +58,7 @@ public function __construct(array $config)
5558
$this->description = $config['description'] ?? null;
5659
$this->astNode = $config['astNode'] ?? null;
5760
$this->extensionASTNodes = $config['extensionASTNodes'] ?? [];
61+
$this->isOneOf = $config['isOneOf'] ?? false;
5862

5963
$this->config = $config;
6064
}
@@ -91,6 +95,12 @@ public function hasField(string $name): bool
9195
return isset($this->fields[$name]);
9296
}
9397

98+
/** Returns true if this is a oneOf input object type. */
99+
public function isOneOf(): bool
100+
{
101+
return $this->isOneOf;
102+
}
103+
94104
/**
95105
* @throws InvariantViolation
96106
*
@@ -196,6 +206,44 @@ public function assertValid(): void
196206
foreach ($resolvedFields as $field) {
197207
$field->assertValid($this);
198208
}
209+
210+
// Additional validation for oneOf input objects
211+
if ($this->isOneOf()) {
212+
$this->validateOneOfConstraints($resolvedFields);
213+
}
214+
}
215+
216+
/**
217+
* Validates that oneOf input object constraints are met.
218+
*
219+
* @param array<string, InputObjectField> $fields
220+
*
221+
* @throws InvariantViolation
222+
*/
223+
private function validateOneOfConstraints(array $fields): void
224+
{
225+
if (count($fields) === 0) {
226+
throw new InvariantViolation("OneOf input object type {$this->name} must define one or more fields.");
227+
}
228+
229+
foreach ($fields as $fieldName => $field) {
230+
$fieldType = $field->getType();
231+
232+
// OneOf fields must be nullable (not wrapped in NonNull)
233+
if ($fieldType instanceof NonNull) {
234+
throw new InvariantViolation("OneOf input object type {$this->name} field {$fieldName} must be nullable.");
235+
}
236+
237+
// OneOf fields cannot have default values
238+
if ($field->defaultValueExists()) {
239+
throw new InvariantViolation("OneOf input object type {$this->name} field {$fieldName} cannot have a default value.");
240+
}
241+
242+
// OneOf fields cannot be deprecated (optional constraint for now)
243+
if ($field->isDeprecated()) {
244+
throw new InvariantViolation("OneOf input object type {$this->name} field {$fieldName} cannot be deprecated.");
245+
}
246+
}
199247
}
200248

201249
public function astNode(): ?InputObjectTypeDefinitionNode

src/Type/Introspection.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,12 @@ public static function _type(): ObjectType
437437
? $type->getWrappedType()
438438
: null,
439439
],
440+
'isOneOf' => [
441+
'type' => Type::boolean(),
442+
'resolve' => static fn ($type): ?bool => $type instanceof InputObjectType
443+
? $type->isOneOf()
444+
: null,
445+
],
440446
],
441447
]);
442448
}

src/Utils/BuildSchema.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,9 @@ static function (string $typeName): Type {
238238
if (! isset($directivesByName['deprecated'])) {
239239
$directives[] = Directive::deprecatedDirective();
240240
}
241+
if (! isset($directivesByName['oneOf'])) {
242+
$directives[] = Directive::oneOfDirective();
243+
}
241244

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

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: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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) || ! $namedType->isOneOf()) {
31+
return;
32+
}
33+
34+
$providedFields = [];
35+
foreach ($node->fields as $fieldNode) {
36+
$fieldName = $fieldNode->name->value;
37+
$providedFields[] = $fieldName;
38+
}
39+
40+
$fieldCount = count($providedFields);
41+
42+
if ($fieldCount === 0) {
43+
$context->reportError(new Error(
44+
static::oneOfInputObjectExpectedExactlyOneFieldMessage($namedType->name),
45+
[$node]
46+
));
47+
} elseif ($fieldCount > 1) {
48+
$context->reportError(new Error(
49+
static::oneOfInputObjectExpectedExactlyOneFieldMessage($namedType->name, $fieldCount),
50+
[$node]
51+
));
52+
}
53+
},
54+
];
55+
}
56+
57+
public static function oneOfInputObjectExpectedExactlyOneFieldMessage(string $typeName, ?int $providedCount = null): string
58+
{
59+
if ($providedCount === null) {
60+
return "OneOf input object '{$typeName}' must specify exactly one field.";
61+
}
62+
63+
return "OneOf input object '{$typeName}' must specify exactly one field, but {$providedCount} fields were provided.";
64+
}
65+
}

0 commit comments

Comments
 (0)