Skip to content
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
33 changes: 28 additions & 5 deletions src/Validator/InputValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,27 @@ private function createValidator(MetadataFactory $metadataFactory): ValidatorInt
return $builder->getValidator();
}

private function getMetadata(ValidationNode $rootObject): ObjectMetadata
{
// Return existing metadata if present
if ($this->metadataFactory->hasMetadataFor($rootObject)) {
return $this->metadataFactory->getMetadataFor($rootObject);
}

// Create new metadata and add it to the factory
$metadata = new ObjectMetadata($rootObject);
$this->metadataFactory->addMetadata($metadata);

return $metadata;
}
Comment on lines +127 to +139
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method reuses metadata if it was already created for the input-object of the same type


/**
* Creates a composition of ValidationNode objects from args
* and simultaneously applies to them validation constraints.
*/
private function buildValidationTree(ValidationNode $rootObject, iterable $fields, array $classValidation, array $inputData): ValidationNode
{
$metadata = new ObjectMetadata($rootObject);
$metadata = $this->getMetadata($rootObject);

if (!empty($classValidation)) {
$this->applyClassValidation($metadata, $classValidation);
Expand All @@ -141,7 +155,6 @@ private function buildValidationTree(ValidationNode $rootObject, iterable $field
$config = static::normalizeConfig($arg['validation'] ?? []);

if (isset($config['cascade']) && isset($inputData[$property])) {
$groups = $config['cascade'];
$argType = $this->unclosure($arg['type']);

/** @var ObjectType|InputObjectType $type */
Expand All @@ -154,18 +167,29 @@ private function buildValidationTree(ValidationNode $rootObject, iterable $field
}

$valid = new Valid();
$groups = $config['cascade'];

if (!empty($groups)) {
$valid->groups = $groups;
}

// Apply the Assert/Valid constraint for a recursive validation.
// For more details see https://symfony.com/doc/current/reference/constraints/Valid.html
$metadata->addPropertyConstraint($property, $valid);

// Skip the rest as the validation was delegated to the nested object.
continue;
Copy link
Contributor Author

@murtukov murtukov Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no need to proceed and add any more constraints if it was set to cascade

} else {
$rootObject->$property = $inputData[$property] ?? null;
}

if ($metadata->hasPropertyMetadata($property)) {
continue;
}
Comment on lines +186 to +188
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If constraints were set for the property, don't add them the second time


$config = static::normalizeConfig($config);

// Apply validation constraints for the property
foreach ($config as $key => $value) {
switch ($key) {
case 'link':
Expand Down Expand Up @@ -195,17 +219,16 @@ private function buildValidationTree(ValidationNode $rootObject, iterable $field
}

break;
case 'constraints':
case 'constraints': // Add constraint from the yml config
$metadata->addPropertyConstraints($property, $value);
break;
case 'cascade':
// Cascade validation was already handled recursively.
break;
}
}
}

$this->metadataFactory->addMetadata($metadata);

return $rootObject;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ Address:
- Choice:
groups: ['group1']
choices: ['New York', 'Berlin', 'Tokyo']
country:
type: Country
validation: cascade
zipCode:
type: Int!
validation:
- Expression: "service_validator.isZipCodeValid(value)"
period:
type: Period!
type: Period
validation: cascade
13 changes: 13 additions & 0 deletions tests/Functional/App/config/validator/mapping/Country.types.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Country:
type: input-object
config:
fields:
name:
type: String
validation:
- NotBlank:
allowNull: true
officialLanguage:
type: String
validation:
- Choice: ['en', 'de', 'fr']
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,11 @@ Mutation:
validation:
cascade:
groups: ['group2']

partialInputObjectsCollectionValidation:
type: Boolean
resolve: "@=m('mutation_mock', args)"
args:
addresses:
type: '[Address]'
validation: cascade
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ Period:
config:
fields:
startDate:
type: String!
type: String
validation:
- Date: ~
- Overblog\GraphQLBundle\Tests\Functional\App\Validator\AtLeastOneOf:
Expand All @@ -12,7 +12,7 @@ Period:
message: "Year should be GreaterThanOrEqual -100."
includeInternalMessages: false
endDate:
type: String!
type: String
validation:
- Expression: "this.getParent().getName() === 'Address'"
- Date: ~
Expand Down
28 changes: 28 additions & 0 deletions tests/Functional/Validator/ExpectedErrors.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,34 @@ final class ExpectedErrors
],
];

public const PARTIAL_INPUT_OBJECTS_COLLECTION = [
'message' => 'validation',
'locations' => [['line' => 3, 'column' => 17]],
'path' => ['partialInputObjectsCollectionValidation'],
'extensions' => [
'validation' => [
'addresses[0].country.officialLanguage' => [
0 => [
'message' => 'The value you selected is not a valid choice.',
'code' => '8e179f1b-97aa-4560-a02f-2a8b42e49df7',
],
],
'addresses[1].country.name' => [
0 => [
'message' => 'This value should not be blank.',
'code' => 'c1051bb4-d103-4f74-8988-acbcafc7fdc3',
],
],
'addresses[1].period.endDate' => [
0 => [
'message' => 'This value should be greater than "2000-01-01".',
'code' => '778b7ae0-84d3-481a-9dec-35fdb64b1d78',
],
],
],
],
];

public static function simpleValidation(string $fieldName): array
{
return [
Expand Down
48 changes: 48 additions & 0 deletions tests/Functional/Validator/InputValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -377,4 +377,52 @@ public function testAutoValidationAutoThrowWithGroupsFails(): void
$this->assertSame(ExpectedErrors::cascadeWithGroups('autoValidationAutoThrowWithGroups'), $result['errors'][0]);
$this->assertNull($result['data']['autoValidationAutoThrowWithGroups']);
}

public function testPartialInputObjectsCollectionValidation(): void
{
$query = '
mutation {
partialInputObjectsCollectionValidation(
addresses: [
{
street: "Washington Street"
city: "Berlin"
zipCode: 10000
# Country is present, but the language is invalid
country: {
name: "Germany"
officialLanguage: "ru"
}
# Period is completely missing, skip validation
},
{
street: "Washington Street"
city: "New York"
zipCode: 10000
# Country is partially present
country: {
name: "" # Name should not be blank
# language is missing
}
period: {
startDate: "2000-01-01"
endDate: "1990-01-01"
}
},
{
street: "Washington Street"
city: "New York"
zipCode: 10000
country: {} # Empty input object, skip validation
period: {} # Empty input object, skip validation
}
]
)
}
';
Comment on lines +383 to +422
Copy link
Contributor Author

@murtukov murtukov Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A new test case to cover scenarios when input objects (country and period) have all fields, partial fields, or no fields at all.


$result = $this->executeGraphQLRequest($query);
$this->assertSame(ExpectedErrors::PARTIAL_INPUT_OBJECTS_COLLECTION, $result['errors'][0]);
$this->assertNull($result['data']['partialInputObjectsCollectionValidation']);
}
}