diff --git a/src/Validator/InputValidator.php b/src/Validator/InputValidator.php index c5ba9f9b7..27a82e9c1 100644 --- a/src/Validator/InputValidator.php +++ b/src/Validator/InputValidator.php @@ -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; + } + /** * 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); @@ -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 */ @@ -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; } else { $rootObject->$property = $inputData[$property] ?? null; } + if ($metadata->hasPropertyMetadata($property)) { + continue; + } + $config = static::normalizeConfig($config); + // Apply validation constraints for the property foreach ($config as $key => $value) { switch ($key) { case 'link': @@ -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; } diff --git a/tests/Functional/App/config/validator/mapping/Address.types.yml b/tests/Functional/App/config/validator/mapping/Address.types.yml index 3426bd408..1e3a6c343 100644 --- a/tests/Functional/App/config/validator/mapping/Address.types.yml +++ b/tests/Functional/App/config/validator/mapping/Address.types.yml @@ -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 diff --git a/tests/Functional/App/config/validator/mapping/Country.types.yml b/tests/Functional/App/config/validator/mapping/Country.types.yml new file mode 100644 index 000000000..09f2f266e --- /dev/null +++ b/tests/Functional/App/config/validator/mapping/Country.types.yml @@ -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'] diff --git a/tests/Functional/App/config/validator/mapping/Mutation.types.yml b/tests/Functional/App/config/validator/mapping/Mutation.types.yml index ede5d948b..6c85fd67c 100644 --- a/tests/Functional/App/config/validator/mapping/Mutation.types.yml +++ b/tests/Functional/App/config/validator/mapping/Mutation.types.yml @@ -138,3 +138,11 @@ Mutation: validation: cascade: groups: ['group2'] + + partialInputObjectsCollectionValidation: + type: Boolean + resolve: "@=m('mutation_mock', args)" + args: + addresses: + type: '[Address]' + validation: cascade diff --git a/tests/Functional/App/config/validator/mapping/Period.types.yml b/tests/Functional/App/config/validator/mapping/Period.types.yml index 909315b75..557506553 100644 --- a/tests/Functional/App/config/validator/mapping/Period.types.yml +++ b/tests/Functional/App/config/validator/mapping/Period.types.yml @@ -3,7 +3,7 @@ Period: config: fields: startDate: - type: String! + type: String validation: - Date: ~ - Overblog\GraphQLBundle\Tests\Functional\App\Validator\AtLeastOneOf: @@ -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: ~ diff --git a/tests/Functional/Validator/ExpectedErrors.php b/tests/Functional/Validator/ExpectedErrors.php index e4f5b34df..5aef2fb9c 100644 --- a/tests/Functional/Validator/ExpectedErrors.php +++ b/tests/Functional/Validator/ExpectedErrors.php @@ -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 [ diff --git a/tests/Functional/Validator/InputValidatorTest.php b/tests/Functional/Validator/InputValidatorTest.php index 4bd76823c..7deae5eed 100644 --- a/tests/Functional/Validator/InputValidatorTest.php +++ b/tests/Functional/Validator/InputValidatorTest.php @@ -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 + } + ] + ) + } + '; + + $result = $this->executeGraphQLRequest($query); + $this->assertSame(ExpectedErrors::PARTIAL_INPUT_OBJECTS_COLLECTION, $result['errors'][0]); + $this->assertNull($result['data']['partialInputObjectsCollectionValidation']); + } }