From e1b29cc0507e03fd772996c0f07bacaf592b7d5a Mon Sep 17 00:00:00 2001 From: murtukov Date: Wed, 5 Nov 2025 05:27:09 +0100 Subject: [PATCH] Add partial input validation for nested objects and collections --- src/Validator/InputValidator.php | 24 ++++++- .../validator/mapping/Address.types.yml | 5 +- .../validator/mapping/Country.types.yml | 13 ++++ .../validator/mapping/Mutation.types.yml | 8 +++ .../config/validator/mapping/Period.types.yml | 4 +- tests/Functional/Validator/ExpectedErrors.php | 28 ++++++++ .../Validator/InputValidatorTest.php | 64 ++++++++++++++++--- 7 files changed, 133 insertions(+), 13 deletions(-) create mode 100644 tests/Functional/App/config/validator/mapping/Country.types.yml diff --git a/src/Validator/InputValidator.php b/src/Validator/InputValidator.php index 0ef4d785f..bff4b29df 100644 --- a/src/Validator/InputValidator.php +++ b/src/Validator/InputValidator.php @@ -73,7 +73,7 @@ public function __construct(array $resolverArgs, ?ValidatorInterface $validator, { if (null === $validator) { throw new ServiceNotFoundException( - "The 'validator' service is not found. To use the 'InputValidator' you need to install the + "The 'validator' service is not found. To use the 'InputValidator' you need to install the Symfony Validator Component first. See: 'https://symfony.com/doc/current/validation.html'" ); } @@ -128,6 +128,20 @@ public function validate($groups = null, bool $throw = true): ?ConstraintViolati } } + 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. @@ -140,7 +154,7 @@ public function validate($groups = null, bool $throw = true): ?ConstraintViolati */ protected function buildValidationTree(ValidationNode $rootObject, array $constraintMapping, array $args): ValidationNode { - $metadata = new ObjectMetadata($rootObject); + $metadata = $this->getMetadata($rootObject); $this->applyClassConstraints($metadata, $constraintMapping['class']); @@ -162,10 +176,16 @@ protected function buildValidationTree(ValidationNode $rootObject, array $constr } $metadata->addPropertyConstraint($property, $valid); + + continue; } else { $rootObject->$property = $args[$property] ?? null; } + if ($metadata->hasPropertyMetadata($property)) { + continue; + } + foreach ($params ?? [] as $key => $value) { if (null === $value) { continue; diff --git a/tests/Functional/App/config/validator/mapping/Address.types.yml b/tests/Functional/App/config/validator/mapping/Address.types.yml index 0c437e2a3..b513043fb 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('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..6774b71b6 --- /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 49c95f766..3003e76de 100644 --- a/tests/Functional/App/config/validator/mapping/Mutation.types.yml +++ b/tests/Functional/App/config/validator/mapping/Mutation.types.yml @@ -126,3 +126,11 @@ Mutation: validation: cascade: groups: ['group2'] + + partialInputObjectsCollectionValidation: + type: Boolean + resolve: "@=mut('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 c01d67b18..69e4ade19 100644 --- a/tests/Functional/App/config/validator/mapping/Period.types.yml +++ b/tests/Functional/App/config/validator/mapping/Period.types.yml @@ -3,11 +3,11 @@ Period: config: fields: startDate: - type: String! + type: String validation: - Date: ~ 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 2c574818e..d3e902071 100644 --- a/tests/Functional/Validator/ExpectedErrors.php +++ b/tests/Functional/Validator/ExpectedErrors.php @@ -83,6 +83,34 @@ class ExpectedErrors 'path' => ['collectionValidation'], ]; + 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($fieldName) { return [ diff --git a/tests/Functional/Validator/InputValidatorTest.php b/tests/Functional/Validator/InputValidatorTest.php index 379a5a5b1..437ee662f 100644 --- a/tests/Functional/Validator/InputValidatorTest.php +++ b/tests/Functional/Validator/InputValidatorTest.php @@ -106,11 +106,11 @@ public function testCollectionValidationPasses(): void mutation { collectionValidation( addresses: [{ - city: "Berlin", - street: "Brettnacher-Str. 14a", - zipCode: 10546, + city: "Berlin", + street: "Brettnacher-Str. 14a", + zipCode: 10546, period: { - startDate: "2016-01-01", + startDate: "2016-01-01", endDate: "2019-07-14" } }] @@ -134,11 +134,11 @@ public function testCollectionValidationFails(): void mutation { collectionValidation( addresses: [{ - city: "Moscow", - street: "ul. Lazo", - zipCode: -15, + city: "Moscow", + street: "ul. Lazo", + zipCode: -15, period: { - startDate: "2020-01-01", + startDate: "2020-01-01", endDate: "2019-07-14" } }] @@ -368,4 +368,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']); + } }