Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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

## v15.20.0

### Added
Expand Down
15 changes: 15 additions & 0 deletions src/Type/Definition/Directive.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,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 @@ -86,6 +87,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 @@ -151,6 +153,19 @@ public static function deprecatedDirective(): Directive
]);
}

/** @throws InvariantViolation */
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' => [],
]);
}

/** @throws InvariantViolation */
public static function isSpecifiedDirective(Directive $directive): bool
{
Expand Down
48 changes: 48 additions & 0 deletions src/Type/Definition/InputObjectType.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
* name?: string|null,
* description?: string|null,
* fields: iterable<FieldConfig>|callable(): iterable<FieldConfig>,
* isOneOf?: bool|null,
* parseValue?: callable(array<string, mixed>): mixed,
* astNode?: InputObjectTypeDefinitionNode|null,
* extensionASTNodes?: array<InputObjectTypeExtensionNode>|null
Expand All @@ -35,6 +36,8 @@
/** @phpstan-var InputObjectConfig */
public array $config;

public bool $isOneOf;

/**
* Lazily initialized.
*
Expand All @@ -55,6 +58,7 @@
$this->description = $config['description'] ?? null;
$this->astNode = $config['astNode'] ?? null;
$this->extensionASTNodes = $config['extensionASTNodes'] ?? [];
$this->isOneOf = $config['isOneOf'] ?? false;

$this->config = $config;
}
Expand Down Expand Up @@ -91,6 +95,12 @@
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 @@ -196,6 +206,44 @@
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.");

Check warning on line 226 in src/Type/Definition/InputObjectType.php

View check run for this annotation

Codecov / codecov/patch

src/Type/Definition/InputObjectType.php#L226

Added line #L226 was not covered by tests
}

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.");
}

// OneOf fields cannot be deprecated (optional constraint for now)
if ($field->isDeprecated()) {
throw new InvariantViolation("OneOf input object type {$this->name} field {$fieldName} cannot be deprecated.");

Check warning on line 244 in src/Type/Definition/InputObjectType.php

View check run for this annotation

Codecov / codecov/patch

src/Type/Definition/InputObjectType.php#L244

Added line #L244 was not covered by tests
}
}
}

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 @@ -437,6 +437,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
36 changes: 34 additions & 2 deletions src/Utils/Value.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ public static function coerceInputValue($value, InputType $type, ?array $path =
$coercedItem = self::coerceInputValue(
$itemValue,
$itemType,
[...$path ?? [], $index]
$path === null ? [$index] : [...$path, $index]
);

if (isset($coercedItem['errors'])) {
Expand Down Expand Up @@ -132,7 +132,7 @@ public static function coerceInputValue($value, InputType $type, ?array $path =
$coercedField = self::coerceInputValue(
$fieldValue,
$field->getType(),
[...$path ?? [], $fieldName],
$path === null ? [$fieldName] : [...$path, $fieldName],
);

if (isset($coercedField['errors'])) {
Expand Down 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
92 changes: 92 additions & 0 deletions src/Validator/Rules/OneOfInputObjectsRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?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