From 9304a6e4d3779d0078e94535327f2d092433e74e Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Mon, 26 Jan 2026 16:14:17 +0200 Subject: [PATCH 01/33] adding symfony8 to supported list --- .github/workflows/ci.yml | 5 +++++ composer.json | 42 ++++++++++++++++++++-------------------- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bcc678df..1f4c3f970 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,12 @@ jobs: symfony-version: - '5.4.*' - '6.4.*' + - '7.0.*' + - '7.1.*' + - '7.2.*' + - '7.3.*' - '7.4.*' + - '8.0.*' dependencies: - 'lowest' - 'highest' diff --git a/composer.json b/composer.json index e2df667bf..1d1679f14 100644 --- a/composer.json +++ b/composer.json @@ -38,15 +38,15 @@ "phpdocumentor/reflection-docblock": "^5.2", "phpdocumentor/type-resolver": "^1.6.1", "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/config": "^5.4.46 || ^6.4.32 || ^7.0", - "symfony/dependency-injection": "^5.4.48 || ^6.4.32 || ^7.0", - "symfony/event-dispatcher": "^5.4.45 || ^6.4.32 || ^7.0", - "symfony/expression-language": "^5.4.45 || ^6.4.32 || ^7.0", - "symfony/framework-bundle": "^5.4.45 || ^6.4.32 || ^7.0", - "symfony/http-foundation": "^5.4.50 || ^6.4.32 || ^7.0", - "symfony/http-kernel": "^5.4.50 || ^6.4.32 || ^7.0", - "symfony/options-resolver": "^5.4.45 || ^6.4.30 || ^7.0", - "symfony/property-access": "^5.4.45 || ^6.4.32 || ^7.0", + "symfony/config": "^5.4.46 || ^6.4.32 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^5.4.48 || ^6.4.32 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.32 || ^7.0 || ^8.0", + "symfony/expression-language": "^5.4.45 || ^6.4.32 || ^7.0 || ^8.0", + "symfony/framework-bundle": "^5.4.45 || ^6.4.32 || ^7.0 || ^8.0", + "symfony/http-foundation": "^5.4.50 || ^6.4.32 || ^7.0 || ^8.0", + "symfony/http-kernel": "^5.4.50 || ^6.4.32 || ^7.0 || ^8.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.30 || ^7.0 || ^8.0", + "symfony/property-access": "^5.4.45 || ^6.4.32 || ^7.0 || ^8.0", "webonyx/graphql-php": "^15.24" }, "suggest": { @@ -66,20 +66,20 @@ "phpstan/phpstan-symfony": "^1.0", "phpunit/phpunit": "^10.5.63", "react/promise": "^2.5", - "symfony/asset": "^5.4.45 || ^6.4.32 || ^7.0", - "symfony/browser-kit": "^5.4.45 || ^6.4.32 || ^7.0", - "symfony/console": "^5.4.47 || ^6.4.32 || ^7.0", - "symfony/css-selector": "^5.4.45 || ^6.4.24 || ^7.0", - "symfony/dom-crawler": "^5.4.48 || ^6.4.32 || ^7.0", - "symfony/finder": "^5.4.45 || ^6.4.32 || ^7.0", + "symfony/asset": "^5.4.45 || ^6.4.32 || ^7.0 || ^8.0", + "symfony/browser-kit": "^5.4.45 || ^6.4.32 || ^7.0 || ^8.0", + "symfony/console": "^5.4.47 || ^6.4.32 || ^7.0 || ^8.0", + "symfony/css-selector": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/dom-crawler": "^5.4.48 || ^6.4.32 || ^7.0 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.32 || ^7.0 || ^8.0", "symfony/monolog-bundle": "^3.7", "symfony/phpunit-bridge": "^7.4.3", - "symfony/process": "^5.4.47 || ^6.4.32 || ^7.0", - "symfony/routing": "^5.4.48 || ^6.4.32 || ^7.0", - "symfony/security-bundle": "^5.4.45 || ^6.4.32 || ^7.0", - "symfony/validator": "^5.4.48 || ^6.4.31 || ^7.0", - "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.0", - "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.0", + "symfony/process": "^5.4.47 || ^6.4.32 || ^7.0 || ^8.0", + "symfony/routing": "^5.4.48 || ^6.4.32 || ^7.0 || ^8.0", + "symfony/security-bundle": "^5.4.45 || ^6.4.32 || ^7.0 || ^8.0", + "symfony/validator": "^5.4.48 || ^6.4.31 || ^7.0 || ^8.0", + "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.0 || ^8.0", + "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.0 || ^8.0", "twig/twig": "^2.10|^3.0" }, "conflict": { From c56bdce59e6af11221ead16eb7e6670f043f536f Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Mon, 26 Jan 2026 16:31:37 +0200 Subject: [PATCH 02/33] one more exclude, monolog bump --- composer.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/composer.json b/composer.json index 1d1679f14..4492cab89 100644 --- a/composer.json +++ b/composer.json @@ -66,6 +66,7 @@ "phpstan/phpstan-symfony": "^1.0", "phpunit/phpunit": "^10.5.63", "react/promise": "^2.5", +<<<<<<< HEAD "symfony/asset": "^5.4.45 || ^6.4.32 || ^7.0 || ^8.0", "symfony/browser-kit": "^5.4.45 || ^6.4.32 || ^7.0 || ^8.0", "symfony/console": "^5.4.47 || ^6.4.32 || ^7.0 || ^8.0", @@ -80,6 +81,22 @@ "symfony/validator": "^5.4.48 || ^6.4.31 || ^7.0 || ^8.0", "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.0 || ^8.0", "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.0 || ^8.0", +======= + "symfony/asset": "^5.4 || ^6.0 || ^7.0", + "symfony/browser-kit": "^5.4 || ^6.0 || ^7.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0", + "symfony/console": "^5.4 || ^6.0 || ^7.0", + "symfony/dom-crawler": "^5.4 || ^6.0 || ^7.0", + "symfony/finder": "^5.4 || ^6.0 || ^7.0", + "symfony/monolog-bundle": "^3.7 || ^4.0", + "symfony/phpunit-bridge": "^6.0", + "symfony/process": "^5.4 || ^6.0 || ^7.0", + "symfony/routing": "^5.4 || ^6.0 || ^7.0", + "symfony/security-bundle": "^5.4 || ^6.0 || ^7.0", + "symfony/validator": "^5.4 || ^6.0 || ^7.0", + "symfony/var-dumper": "^5.4 || ^6.0 || ^7.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0", +>>>>>>> a36b87b9 (one more exclude, monolog bump) "twig/twig": "^2.10|^3.0" }, "conflict": { From 205dbed818c56e74f350173cfd3f2e6d81dca9b8 Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Mon, 26 Jan 2026 16:36:16 +0200 Subject: [PATCH 03/33] bump doctrine --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 4492cab89..b4b14d34e 100644 --- a/composer.json +++ b/composer.json @@ -57,7 +57,7 @@ }, "require-dev": { "doctrine/annotations": "^1.13", - "doctrine/orm": "^2.5", + "doctrine/orm": "^2.5 || ^3.6", "monolog/monolog": "^2.8.0 || ^3.0", "php-cs-fixer/shim": "^3.93", "phpstan/extension-installer": "^1.0", From 4eed4321391926ac6755fb69dbba4adf695b3def Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Mon, 26 Jan 2026 16:44:17 +0200 Subject: [PATCH 04/33] doctrine annotations also --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index b4b14d34e..0de651edb 100644 --- a/composer.json +++ b/composer.json @@ -56,7 +56,7 @@ "symfony/translation": "If you want validation error messages to be translated." }, "require-dev": { - "doctrine/annotations": "^1.13", + "doctrine/annotations": "^1.13 || ^2.0", "doctrine/orm": "^2.5 || ^3.6", "monolog/monolog": "^2.8.0 || ^3.0", "php-cs-fixer/shim": "^3.93", From 53063a68a99df8b3acb817dc5790176353fa9dcf Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Mon, 26 Jan 2026 16:47:19 +0200 Subject: [PATCH 05/33] Revert "doctrine annotations also" This reverts commit aa26b819127ee4cbc4f602242f563f42759f0db0. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 0de651edb..b4b14d34e 100644 --- a/composer.json +++ b/composer.json @@ -56,7 +56,7 @@ "symfony/translation": "If you want validation error messages to be translated." }, "require-dev": { - "doctrine/annotations": "^1.13 || ^2.0", + "doctrine/annotations": "^1.13", "doctrine/orm": "^2.5 || ^3.6", "monolog/monolog": "^2.8.0 || ^3.0", "php-cs-fixer/shim": "^3.93", From a885336e50130a495423c1a6e456c7ed5d14d7e1 Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Mon, 26 Jan 2026 17:32:34 +0200 Subject: [PATCH 06/33] annotations 2.0 once more --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index b4b14d34e..0de651edb 100644 --- a/composer.json +++ b/composer.json @@ -56,7 +56,7 @@ "symfony/translation": "If you want validation error messages to be translated." }, "require-dev": { - "doctrine/annotations": "^1.13", + "doctrine/annotations": "^1.13 || ^2.0", "doctrine/orm": "^2.5 || ^3.6", "monolog/monolog": "^2.8.0 || ^3.0", "php-cs-fixer/shim": "^3.93", From 09513a4365c4c0130e09ec4d29174544088f7670 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Mon, 26 Jan 2026 17:18:33 +0100 Subject: [PATCH 07/33] opt-out from annotation if doctrine/orm >= 3 --- composer.json | 4 ++-- .../Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php | 3 ++- tests/Config/Parser/TestMetadataParser.php | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 0de651edb..40006558e 100644 --- a/composer.json +++ b/composer.json @@ -56,8 +56,8 @@ "symfony/translation": "If you want validation error messages to be translated." }, "require-dev": { - "doctrine/annotations": "^1.13 || ^2.0", - "doctrine/orm": "^2.5 || ^3.6", + "doctrine/annotations": "^1.14|^2.0", + "doctrine/orm": "^2.20.9 || ^3.6", "monolog/monolog": "^2.8.0 || ^3.0", "php-cs-fixer/shim": "^3.93", "phpstan/extension-installer": "^1.0", diff --git a/src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php b/src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php index a65d18631..3d109184e 100644 --- a/src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php +++ b/src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php @@ -35,7 +35,8 @@ public function getName(): string public function supports(Reflector $reflector): bool { - return $reflector instanceof ReflectionProperty; + // If we are on doctrine/orm v2 + return class_exists(\Doctrine\ORM\Version::class) && $reflector instanceof ReflectionProperty; } /** diff --git a/tests/Config/Parser/TestMetadataParser.php b/tests/Config/Parser/TestMetadataParser.php index f0032dca6..5a5e88184 100644 --- a/tests/Config/Parser/TestMetadataParser.php +++ b/tests/Config/Parser/TestMetadataParser.php @@ -90,7 +90,7 @@ public static function isDoctrineAnnotationInstalled(): bool public static function isDoctrineOrmInstalled(): bool { - return class_exists(Column::class); + return class_exists(Column::class) && class_exists(\Doctrine\ORM\Version::class); } protected function expect(string $name, string $type, array $config = []): void From 12a316ae9c1ae3cfd8de462b6fa3226faec0a696 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Mon, 26 Jan 2026 17:35:50 +0100 Subject: [PATCH 08/33] fix: bridge --- composer.json | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/composer.json b/composer.json index 40006558e..0bf3a7ded 100644 --- a/composer.json +++ b/composer.json @@ -66,7 +66,6 @@ "phpstan/phpstan-symfony": "^1.0", "phpunit/phpunit": "^10.5.63", "react/promise": "^2.5", -<<<<<<< HEAD "symfony/asset": "^5.4.45 || ^6.4.32 || ^7.0 || ^8.0", "symfony/browser-kit": "^5.4.45 || ^6.4.32 || ^7.0 || ^8.0", "symfony/console": "^5.4.47 || ^6.4.32 || ^7.0 || ^8.0", @@ -81,22 +80,6 @@ "symfony/validator": "^5.4.48 || ^6.4.31 || ^7.0 || ^8.0", "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.0 || ^8.0", "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.0 || ^8.0", -======= - "symfony/asset": "^5.4 || ^6.0 || ^7.0", - "symfony/browser-kit": "^5.4 || ^6.0 || ^7.0", - "symfony/css-selector": "^5.4 || ^6.0 || ^7.0", - "symfony/console": "^5.4 || ^6.0 || ^7.0", - "symfony/dom-crawler": "^5.4 || ^6.0 || ^7.0", - "symfony/finder": "^5.4 || ^6.0 || ^7.0", - "symfony/monolog-bundle": "^3.7 || ^4.0", - "symfony/phpunit-bridge": "^6.0", - "symfony/process": "^5.4 || ^6.0 || ^7.0", - "symfony/routing": "^5.4 || ^6.0 || ^7.0", - "symfony/security-bundle": "^5.4 || ^6.0 || ^7.0", - "symfony/validator": "^5.4 || ^6.0 || ^7.0", - "symfony/var-dumper": "^5.4 || ^6.0 || ^7.0", - "symfony/yaml": "^5.4 || ^6.0 || ^7.0", ->>>>>>> a36b87b9 (one more exclude, monolog bump) "twig/twig": "^2.10|^3.0" }, "conflict": { From 955125f46cacbb7cb965711cb1fd79bfb398f895 Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Tue, 27 Jan 2026 13:39:20 +0200 Subject: [PATCH 09/33] eliminate phpstan concenrns --- phpstan-baseline.neon | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 2a2ed6e0e..3805e48e1 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -590,3 +590,22 @@ parameters: count: 1 path: tests/Relay/Connection/Output/DeprecatedPropertyPublicAccessTraitTest.php + - + message: "#^Call to function method_exists\\(\\) with '.*AnnotationRegistry' and 'registerLoader' will always evaluate to false\\.$#" + count: 1 + path: src/Config/Parser/AnnotationParser.php + + - + message: "#^Call to an undefined static method Doctrine\\\\Common\\\\Annotations\\\\AnnotationRegistry\\:\\:registerLoader\\(\\)\\.$#" + count: 1 + path: src/Config/Parser/AnnotationParser.php + + - + message: "#^Method Overblog\\\\GraphQLBundle\\\\Config\\\\Parser\\\\MetadataParser\\\\TypeGuesser\\\\DoctrineTypeGuesser\\:\\:getAnnotation\\(\\) has invalid return type Doctrine\\\\ORM\\\\Mapping\\\\Annotation\\.$#" + count: 1 + path: src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php + + - + message: "#^PHPDoc tag @var for variable \\$annotation contains unknown class Doctrine\\\\ORM\\\\Mapping\\\\Annotation\\.$#" + count: 1 + path: src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php From 5cb5226a115d44ac926a617b0e5543e8bdff228c Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Tue, 27 Jan 2026 18:21:10 +0200 Subject: [PATCH 10/33] trying to make validation tests pass --- src/Generator/TypeBuilder.php | 30 +++++++++++++++++-- src/Resources/config/services.yaml | 1 + src/Validator/InputValidator.php | 16 +++++++++- .../validator/mapping/Mutation.types.yml | 8 ++--- 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/Generator/TypeBuilder.php b/src/Generator/TypeBuilder.php index 6efb9c88c..b948c8350 100644 --- a/src/Generator/TypeBuilder.php +++ b/src/Generator/TypeBuilder.php @@ -635,10 +635,34 @@ private function buildConstraints(array $constraints = [], bool $inClosure = tru } if (is_array($args)) { - if (isset($args[0]) && is_array($args[0])) { + $reflectionClass = new \ReflectionClass($fqcn); + $constructor = $reflectionClass->getConstructor(); + + $inlineParameters = false; + if ($constructor !== null) { + $parameterNames = []; + $parameters = $constructor->getParameters(); + foreach ($parameters as $parameter) { + $name = $parameter->getName(); + $parameterNames[] = $name; + } + + $checkedPosition = 0; + foreach ($args as $key => $value) { + if ( + isset($parameterNames[$checkedPosition]) === true + && $parameterNames[$checkedPosition++] === $key + ) { + $instance->addArgument($value); + $inlineParameters = true; + } + } + } + + if (isset($args[0]) && is_array($args[0]) && $inlineParameters === false) { // Nested instance $instance->addArgument($this->buildConstraints($args, false)); - } elseif (isset($args['constraints'][0]) && is_array($args['constraints'][0])) { + } elseif (isset($args['constraints'][0]) && is_array($args['constraints'][0]) && $inlineParameters === false) { // Nested instance with "constraints" key (full syntax) $options = [ 'constraints' => $this->buildConstraints($args['constraints'], false), @@ -653,7 +677,7 @@ private function buildConstraints(array $constraints = [], bool $inClosure = tru } $instance->addArgument($options); - } else { + } elseif ($inlineParameters === false) { // Numeric or Assoc array? $instance->addArgument(isset($args[0]) ? $args : Collection::assoc($args)); } diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml index 33d004919..1f36cc57e 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -126,5 +126,6 @@ services: - "@?validator.validator_factory" - "@?validator" - "@?translator.default" + - tags: - { name: overblog_graphql.service, alias: input_validator_factory, public: false } diff --git a/src/Validator/InputValidator.php b/src/Validator/InputValidator.php index c52f45c51..cf864ec64 100644 --- a/src/Validator/InputValidator.php +++ b/src/Validator/InputValidator.php @@ -29,6 +29,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; use function in_array; +use Symfony\Component\Validator\Context\ExecutionContextFactoryInterface; final class InputValidator { @@ -42,6 +43,7 @@ final class InputValidator private ResolveInfo $info; private ConstraintValidatorFactoryInterface $constraintValidatorFactory; private ?TranslatorInterface $defaultTranslator; + private ExecutionContextFactoryInterface $contextFactory; /** @var ClassMetadataInterface[] */ private array $cachedMetadata = []; @@ -50,7 +52,8 @@ public function __construct( ResolverArgs $resolverArgs, ValidatorInterface $validator, ConstraintValidatorFactoryInterface $constraintValidatorFactory, - ?TranslatorInterface $translator + ?TranslatorInterface $translator, + ExecutionContextFactoryInterface $contextFactory ) { $this->resolverArgs = $resolverArgs; $this->info = $this->resolverArgs->info; @@ -58,6 +61,7 @@ public function __construct( $this->constraintValidatorFactory = $constraintValidatorFactory; $this->defaultTranslator = $translator; $this->metadataFactory = new MetadataFactory(); + $this->contextFactory = $contextFactory; } /** @@ -82,6 +86,16 @@ public function validate(string|array|null $groups = null, bool $throw = true): ); $validator = $this->createValidator($this->metadataFactory); + $context = $this->contextFactory->createContext( + $validator, + $rootNode + ); + $recursiveContextualValidator = $validator->inContext($context); + $recursiveContextualValidator->validate( + $rootNode, + null, + $groups + ); $errors = $validator->validate($rootNode, null, $groups); diff --git a/tests/Functional/App/config/validator/mapping/Mutation.types.yml b/tests/Functional/App/config/validator/mapping/Mutation.types.yml index 6c85fd67c..74ef08114 100644 --- a/tests/Functional/App/config/validator/mapping/Mutation.types.yml +++ b/tests/Functional/App/config/validator/mapping/Mutation.types.yml @@ -29,7 +29,7 @@ Mutation: username: type: String! validation: - - Length: { min: 5 } + - Length: { exactly: null, min: 5 } - Regex: pattern: '/^[a-z]+$/i' htmlPattern: true @@ -63,7 +63,7 @@ Mutation: type: '[String]!' validation: - Unique: ~ - - Count: { min: 3 } + - Count: { exactly: null, min: 3 } - All: - Email: message: 'The email "{{ value }}" is not a valid email.' @@ -108,7 +108,7 @@ Mutation: username: type: String! validation: - - Length: { min: 5 } + - Length: { exactly: null, min: 5 } - Regex: pattern: '/^[a-z]+$/i' htmlPattern: true @@ -120,7 +120,7 @@ Mutation: username: type: String! validation: - - Length: { min: 5 } + - Length: { exactly: null, min: 5 } - Regex: pattern: '/^[a-z]+$/i' htmlPattern: true From 779fdcbfcf5471417740b7b360b67ae41ab25b76 Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Wed, 28 Jan 2026 10:31:16 +0200 Subject: [PATCH 11/33] Revert "trying to make validation tests pass" This reverts commit 2553168d16b21b6952d13a9f371d261911bc0d14. --- src/Generator/TypeBuilder.php | 30 ++----------------- src/Resources/config/services.yaml | 1 - src/Validator/InputValidator.php | 16 +--------- .../validator/mapping/Mutation.types.yml | 8 ++--- 4 files changed, 8 insertions(+), 47 deletions(-) diff --git a/src/Generator/TypeBuilder.php b/src/Generator/TypeBuilder.php index b948c8350..6efb9c88c 100644 --- a/src/Generator/TypeBuilder.php +++ b/src/Generator/TypeBuilder.php @@ -635,34 +635,10 @@ private function buildConstraints(array $constraints = [], bool $inClosure = tru } if (is_array($args)) { - $reflectionClass = new \ReflectionClass($fqcn); - $constructor = $reflectionClass->getConstructor(); - - $inlineParameters = false; - if ($constructor !== null) { - $parameterNames = []; - $parameters = $constructor->getParameters(); - foreach ($parameters as $parameter) { - $name = $parameter->getName(); - $parameterNames[] = $name; - } - - $checkedPosition = 0; - foreach ($args as $key => $value) { - if ( - isset($parameterNames[$checkedPosition]) === true - && $parameterNames[$checkedPosition++] === $key - ) { - $instance->addArgument($value); - $inlineParameters = true; - } - } - } - - if (isset($args[0]) && is_array($args[0]) && $inlineParameters === false) { + if (isset($args[0]) && is_array($args[0])) { // Nested instance $instance->addArgument($this->buildConstraints($args, false)); - } elseif (isset($args['constraints'][0]) && is_array($args['constraints'][0]) && $inlineParameters === false) { + } elseif (isset($args['constraints'][0]) && is_array($args['constraints'][0])) { // Nested instance with "constraints" key (full syntax) $options = [ 'constraints' => $this->buildConstraints($args['constraints'], false), @@ -677,7 +653,7 @@ private function buildConstraints(array $constraints = [], bool $inClosure = tru } $instance->addArgument($options); - } elseif ($inlineParameters === false) { + } else { // Numeric or Assoc array? $instance->addArgument(isset($args[0]) ? $args : Collection::assoc($args)); } diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml index 1f36cc57e..33d004919 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -126,6 +126,5 @@ services: - "@?validator.validator_factory" - "@?validator" - "@?translator.default" - - tags: - { name: overblog_graphql.service, alias: input_validator_factory, public: false } diff --git a/src/Validator/InputValidator.php b/src/Validator/InputValidator.php index cf864ec64..c52f45c51 100644 --- a/src/Validator/InputValidator.php +++ b/src/Validator/InputValidator.php @@ -29,7 +29,6 @@ use Symfony\Contracts\Translation\TranslatorInterface; use function in_array; -use Symfony\Component\Validator\Context\ExecutionContextFactoryInterface; final class InputValidator { @@ -43,7 +42,6 @@ final class InputValidator private ResolveInfo $info; private ConstraintValidatorFactoryInterface $constraintValidatorFactory; private ?TranslatorInterface $defaultTranslator; - private ExecutionContextFactoryInterface $contextFactory; /** @var ClassMetadataInterface[] */ private array $cachedMetadata = []; @@ -52,8 +50,7 @@ public function __construct( ResolverArgs $resolverArgs, ValidatorInterface $validator, ConstraintValidatorFactoryInterface $constraintValidatorFactory, - ?TranslatorInterface $translator, - ExecutionContextFactoryInterface $contextFactory + ?TranslatorInterface $translator ) { $this->resolverArgs = $resolverArgs; $this->info = $this->resolverArgs->info; @@ -61,7 +58,6 @@ public function __construct( $this->constraintValidatorFactory = $constraintValidatorFactory; $this->defaultTranslator = $translator; $this->metadataFactory = new MetadataFactory(); - $this->contextFactory = $contextFactory; } /** @@ -86,16 +82,6 @@ public function validate(string|array|null $groups = null, bool $throw = true): ); $validator = $this->createValidator($this->metadataFactory); - $context = $this->contextFactory->createContext( - $validator, - $rootNode - ); - $recursiveContextualValidator = $validator->inContext($context); - $recursiveContextualValidator->validate( - $rootNode, - null, - $groups - ); $errors = $validator->validate($rootNode, null, $groups); diff --git a/tests/Functional/App/config/validator/mapping/Mutation.types.yml b/tests/Functional/App/config/validator/mapping/Mutation.types.yml index 74ef08114..6c85fd67c 100644 --- a/tests/Functional/App/config/validator/mapping/Mutation.types.yml +++ b/tests/Functional/App/config/validator/mapping/Mutation.types.yml @@ -29,7 +29,7 @@ Mutation: username: type: String! validation: - - Length: { exactly: null, min: 5 } + - Length: { min: 5 } - Regex: pattern: '/^[a-z]+$/i' htmlPattern: true @@ -63,7 +63,7 @@ Mutation: type: '[String]!' validation: - Unique: ~ - - Count: { exactly: null, min: 3 } + - Count: { min: 3 } - All: - Email: message: 'The email "{{ value }}" is not a valid email.' @@ -108,7 +108,7 @@ Mutation: username: type: String! validation: - - Length: { exactly: null, min: 5 } + - Length: { min: 5 } - Regex: pattern: '/^[a-z]+$/i' htmlPattern: true @@ -120,7 +120,7 @@ Mutation: username: type: String! validation: - - Length: { exactly: null, min: 5 } + - Length: { min: 5 } - Regex: pattern: '/^[a-z]+$/i' htmlPattern: true From e7b080fc49d6774d76682fa927f3322dc619a55b Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Wed, 28 Jan 2026 10:39:46 +0200 Subject: [PATCH 12/33] do not skip tests --- tests/Config/Parser/TestMetadataParser.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Config/Parser/TestMetadataParser.php b/tests/Config/Parser/TestMetadataParser.php index 5a5e88184..f0032dca6 100644 --- a/tests/Config/Parser/TestMetadataParser.php +++ b/tests/Config/Parser/TestMetadataParser.php @@ -90,7 +90,7 @@ public static function isDoctrineAnnotationInstalled(): bool public static function isDoctrineOrmInstalled(): bool { - return class_exists(Column::class) && class_exists(\Doctrine\ORM\Version::class); + return class_exists(Column::class); } protected function expect(string $name, string $type, array $config = []): void From e2ebc08b27abd3d3371cb4825fae384e7a7f1e0e Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Wed, 28 Jan 2026 12:50:47 +0200 Subject: [PATCH 13/33] Revert "do not skip tests" This reverts commit d100da0c3f50ae7e9a057c0e36276b3548e51e95. --- tests/Config/Parser/TestMetadataParser.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Config/Parser/TestMetadataParser.php b/tests/Config/Parser/TestMetadataParser.php index f0032dca6..5a5e88184 100644 --- a/tests/Config/Parser/TestMetadataParser.php +++ b/tests/Config/Parser/TestMetadataParser.php @@ -90,7 +90,7 @@ public static function isDoctrineAnnotationInstalled(): bool public static function isDoctrineOrmInstalled(): bool { - return class_exists(Column::class); + return class_exists(Column::class) && class_exists(\Doctrine\ORM\Version::class); } protected function expect(string $name, string $type, array $config = []): void From 39fdcd5df634e1540ffda13b6c6d8b2bd39ae860 Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Wed, 28 Jan 2026 12:59:39 +0200 Subject: [PATCH 14/33] monolog bump --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 0bf3a7ded..d4c5338f5 100644 --- a/composer.json +++ b/composer.json @@ -72,7 +72,7 @@ "symfony/css-selector": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", "symfony/dom-crawler": "^5.4.48 || ^6.4.32 || ^7.0 || ^8.0", "symfony/finder": "^5.4.45 || ^6.4.32 || ^7.0 || ^8.0", - "symfony/monolog-bundle": "^3.7", + "symfony/monolog-bundle": "^3.7 || ^4.0", "symfony/phpunit-bridge": "^7.4.3", "symfony/process": "^5.4.47 || ^6.4.32 || ^7.0 || ^8.0", "symfony/routing": "^5.4.48 || ^6.4.32 || ^7.0 || ^8.0", From b7a048fcd89653af3d0670caedb6c50beefbf541 Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Wed, 28 Jan 2026 13:03:12 +0200 Subject: [PATCH 15/33] Revert "Revert "trying to make validation tests pass"" This reverts commit b987e43b9db627b44dab0d67cfd21d601888957a. --- src/Generator/TypeBuilder.php | 30 +++++++++++++++++-- src/Resources/config/services.yaml | 1 + src/Validator/InputValidator.php | 16 +++++++++- .../validator/mapping/Mutation.types.yml | 8 ++--- 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/Generator/TypeBuilder.php b/src/Generator/TypeBuilder.php index 6efb9c88c..b948c8350 100644 --- a/src/Generator/TypeBuilder.php +++ b/src/Generator/TypeBuilder.php @@ -635,10 +635,34 @@ private function buildConstraints(array $constraints = [], bool $inClosure = tru } if (is_array($args)) { - if (isset($args[0]) && is_array($args[0])) { + $reflectionClass = new \ReflectionClass($fqcn); + $constructor = $reflectionClass->getConstructor(); + + $inlineParameters = false; + if ($constructor !== null) { + $parameterNames = []; + $parameters = $constructor->getParameters(); + foreach ($parameters as $parameter) { + $name = $parameter->getName(); + $parameterNames[] = $name; + } + + $checkedPosition = 0; + foreach ($args as $key => $value) { + if ( + isset($parameterNames[$checkedPosition]) === true + && $parameterNames[$checkedPosition++] === $key + ) { + $instance->addArgument($value); + $inlineParameters = true; + } + } + } + + if (isset($args[0]) && is_array($args[0]) && $inlineParameters === false) { // Nested instance $instance->addArgument($this->buildConstraints($args, false)); - } elseif (isset($args['constraints'][0]) && is_array($args['constraints'][0])) { + } elseif (isset($args['constraints'][0]) && is_array($args['constraints'][0]) && $inlineParameters === false) { // Nested instance with "constraints" key (full syntax) $options = [ 'constraints' => $this->buildConstraints($args['constraints'], false), @@ -653,7 +677,7 @@ private function buildConstraints(array $constraints = [], bool $inClosure = tru } $instance->addArgument($options); - } else { + } elseif ($inlineParameters === false) { // Numeric or Assoc array? $instance->addArgument(isset($args[0]) ? $args : Collection::assoc($args)); } diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml index 33d004919..1f36cc57e 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -126,5 +126,6 @@ services: - "@?validator.validator_factory" - "@?validator" - "@?translator.default" + - tags: - { name: overblog_graphql.service, alias: input_validator_factory, public: false } diff --git a/src/Validator/InputValidator.php b/src/Validator/InputValidator.php index c52f45c51..cf864ec64 100644 --- a/src/Validator/InputValidator.php +++ b/src/Validator/InputValidator.php @@ -29,6 +29,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; use function in_array; +use Symfony\Component\Validator\Context\ExecutionContextFactoryInterface; final class InputValidator { @@ -42,6 +43,7 @@ final class InputValidator private ResolveInfo $info; private ConstraintValidatorFactoryInterface $constraintValidatorFactory; private ?TranslatorInterface $defaultTranslator; + private ExecutionContextFactoryInterface $contextFactory; /** @var ClassMetadataInterface[] */ private array $cachedMetadata = []; @@ -50,7 +52,8 @@ public function __construct( ResolverArgs $resolverArgs, ValidatorInterface $validator, ConstraintValidatorFactoryInterface $constraintValidatorFactory, - ?TranslatorInterface $translator + ?TranslatorInterface $translator, + ExecutionContextFactoryInterface $contextFactory ) { $this->resolverArgs = $resolverArgs; $this->info = $this->resolverArgs->info; @@ -58,6 +61,7 @@ public function __construct( $this->constraintValidatorFactory = $constraintValidatorFactory; $this->defaultTranslator = $translator; $this->metadataFactory = new MetadataFactory(); + $this->contextFactory = $contextFactory; } /** @@ -82,6 +86,16 @@ public function validate(string|array|null $groups = null, bool $throw = true): ); $validator = $this->createValidator($this->metadataFactory); + $context = $this->contextFactory->createContext( + $validator, + $rootNode + ); + $recursiveContextualValidator = $validator->inContext($context); + $recursiveContextualValidator->validate( + $rootNode, + null, + $groups + ); $errors = $validator->validate($rootNode, null, $groups); diff --git a/tests/Functional/App/config/validator/mapping/Mutation.types.yml b/tests/Functional/App/config/validator/mapping/Mutation.types.yml index 6c85fd67c..74ef08114 100644 --- a/tests/Functional/App/config/validator/mapping/Mutation.types.yml +++ b/tests/Functional/App/config/validator/mapping/Mutation.types.yml @@ -29,7 +29,7 @@ Mutation: username: type: String! validation: - - Length: { min: 5 } + - Length: { exactly: null, min: 5 } - Regex: pattern: '/^[a-z]+$/i' htmlPattern: true @@ -63,7 +63,7 @@ Mutation: type: '[String]!' validation: - Unique: ~ - - Count: { min: 3 } + - Count: { exactly: null, min: 3 } - All: - Email: message: 'The email "{{ value }}" is not a valid email.' @@ -108,7 +108,7 @@ Mutation: username: type: String! validation: - - Length: { min: 5 } + - Length: { exactly: null, min: 5 } - Regex: pattern: '/^[a-z]+$/i' htmlPattern: true @@ -120,7 +120,7 @@ Mutation: username: type: String! validation: - - Length: { min: 5 } + - Length: { exactly: null, min: 5 } - Regex: pattern: '/^[a-z]+$/i' htmlPattern: true From caa61b3faf533a18187cd376dcb7538448e17253 Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Wed, 28 Jan 2026 13:16:13 +0200 Subject: [PATCH 16/33] Revert "Revert "Revert "trying to make validation tests pass""" This reverts commit ef4fb3a3a31f95e7013251f972486dd4cc1fb6c0. --- src/Generator/TypeBuilder.php | 30 ++----------------- src/Resources/config/services.yaml | 1 - src/Validator/InputValidator.php | 16 +--------- .../validator/mapping/Mutation.types.yml | 8 ++--- 4 files changed, 8 insertions(+), 47 deletions(-) diff --git a/src/Generator/TypeBuilder.php b/src/Generator/TypeBuilder.php index b948c8350..6efb9c88c 100644 --- a/src/Generator/TypeBuilder.php +++ b/src/Generator/TypeBuilder.php @@ -635,34 +635,10 @@ private function buildConstraints(array $constraints = [], bool $inClosure = tru } if (is_array($args)) { - $reflectionClass = new \ReflectionClass($fqcn); - $constructor = $reflectionClass->getConstructor(); - - $inlineParameters = false; - if ($constructor !== null) { - $parameterNames = []; - $parameters = $constructor->getParameters(); - foreach ($parameters as $parameter) { - $name = $parameter->getName(); - $parameterNames[] = $name; - } - - $checkedPosition = 0; - foreach ($args as $key => $value) { - if ( - isset($parameterNames[$checkedPosition]) === true - && $parameterNames[$checkedPosition++] === $key - ) { - $instance->addArgument($value); - $inlineParameters = true; - } - } - } - - if (isset($args[0]) && is_array($args[0]) && $inlineParameters === false) { + if (isset($args[0]) && is_array($args[0])) { // Nested instance $instance->addArgument($this->buildConstraints($args, false)); - } elseif (isset($args['constraints'][0]) && is_array($args['constraints'][0]) && $inlineParameters === false) { + } elseif (isset($args['constraints'][0]) && is_array($args['constraints'][0])) { // Nested instance with "constraints" key (full syntax) $options = [ 'constraints' => $this->buildConstraints($args['constraints'], false), @@ -677,7 +653,7 @@ private function buildConstraints(array $constraints = [], bool $inClosure = tru } $instance->addArgument($options); - } elseif ($inlineParameters === false) { + } else { // Numeric or Assoc array? $instance->addArgument(isset($args[0]) ? $args : Collection::assoc($args)); } diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml index 1f36cc57e..33d004919 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -126,6 +126,5 @@ services: - "@?validator.validator_factory" - "@?validator" - "@?translator.default" - - tags: - { name: overblog_graphql.service, alias: input_validator_factory, public: false } diff --git a/src/Validator/InputValidator.php b/src/Validator/InputValidator.php index cf864ec64..c52f45c51 100644 --- a/src/Validator/InputValidator.php +++ b/src/Validator/InputValidator.php @@ -29,7 +29,6 @@ use Symfony\Contracts\Translation\TranslatorInterface; use function in_array; -use Symfony\Component\Validator\Context\ExecutionContextFactoryInterface; final class InputValidator { @@ -43,7 +42,6 @@ final class InputValidator private ResolveInfo $info; private ConstraintValidatorFactoryInterface $constraintValidatorFactory; private ?TranslatorInterface $defaultTranslator; - private ExecutionContextFactoryInterface $contextFactory; /** @var ClassMetadataInterface[] */ private array $cachedMetadata = []; @@ -52,8 +50,7 @@ public function __construct( ResolverArgs $resolverArgs, ValidatorInterface $validator, ConstraintValidatorFactoryInterface $constraintValidatorFactory, - ?TranslatorInterface $translator, - ExecutionContextFactoryInterface $contextFactory + ?TranslatorInterface $translator ) { $this->resolverArgs = $resolverArgs; $this->info = $this->resolverArgs->info; @@ -61,7 +58,6 @@ public function __construct( $this->constraintValidatorFactory = $constraintValidatorFactory; $this->defaultTranslator = $translator; $this->metadataFactory = new MetadataFactory(); - $this->contextFactory = $contextFactory; } /** @@ -86,16 +82,6 @@ public function validate(string|array|null $groups = null, bool $throw = true): ); $validator = $this->createValidator($this->metadataFactory); - $context = $this->contextFactory->createContext( - $validator, - $rootNode - ); - $recursiveContextualValidator = $validator->inContext($context); - $recursiveContextualValidator->validate( - $rootNode, - null, - $groups - ); $errors = $validator->validate($rootNode, null, $groups); diff --git a/tests/Functional/App/config/validator/mapping/Mutation.types.yml b/tests/Functional/App/config/validator/mapping/Mutation.types.yml index 74ef08114..6c85fd67c 100644 --- a/tests/Functional/App/config/validator/mapping/Mutation.types.yml +++ b/tests/Functional/App/config/validator/mapping/Mutation.types.yml @@ -29,7 +29,7 @@ Mutation: username: type: String! validation: - - Length: { exactly: null, min: 5 } + - Length: { min: 5 } - Regex: pattern: '/^[a-z]+$/i' htmlPattern: true @@ -63,7 +63,7 @@ Mutation: type: '[String]!' validation: - Unique: ~ - - Count: { exactly: null, min: 3 } + - Count: { min: 3 } - All: - Email: message: 'The email "{{ value }}" is not a valid email.' @@ -108,7 +108,7 @@ Mutation: username: type: String! validation: - - Length: { exactly: null, min: 5 } + - Length: { min: 5 } - Regex: pattern: '/^[a-z]+$/i' htmlPattern: true @@ -120,7 +120,7 @@ Mutation: username: type: String! validation: - - Length: { exactly: null, min: 5 } + - Length: { min: 5 } - Regex: pattern: '/^[a-z]+$/i' htmlPattern: true From 31a2b156d6f7b8b95928a012d20c2413aa6437a7 Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Wed, 28 Jan 2026 14:39:08 +0200 Subject: [PATCH 17/33] works for php8.4, sf8 --- docs/validation/index.md | 345 +++++++++--------- source | 279 ++++++++++++++ src/Generator/TypeBuilder.php | 30 +- .../validator/mapping/Address.types.yml | 14 +- .../validator/mapping/Birthdate.types.yml | 35 +- .../validator/mapping/Country.types.yml | 6 +- .../validator/mapping/Mutation.types.yml | 19 +- .../config/validator/mapping/Period.types.yml | 1 + 8 files changed, 542 insertions(+), 187 deletions(-) create mode 100644 source diff --git a/docs/validation/index.md b/docs/validation/index.md index 0c640de7d..7390ec711 100644 --- a/docs/validation/index.md +++ b/docs/validation/index.md @@ -2,41 +2,49 @@ # Validation -This bundle provides a tight integration with the [Symfony Validator Component](https://symfony.com/doc/current/components/validator.html) +This bundle provides a tight integration with the [Symfony Validator Component](https://symfony.com/doc/current/components/validator.html) to validate user input data. It currently supports only GraphQL schemas defined with YAML. ### Contents: -- [Overview](#overview) -- [How does it work?](#how-does-it-work) -- [Applying validation constraints](#applying-validation-constraints) +- [Validation](#validation) + - [Contents:](#contents) + - [Overview](#overview) + - [How does it work?](#how-does-it-work) + - [Applying validation constraints](#applying-validation-constraints) - [Listing constraints directly](#listing-constraints-directly) - - [object](#object) - - [input-object](#input-object) + - [object:](#object) + - [input-object:](#input-object) - [Linking to class constraints](#linking-to-class-constraints) - - [Context of linked constraints](#context-of-linked-constraints) - - [Validation groups of linked constraints](#validation-groups-of-linked-constraints) + - [Example:](#example) + - [Context of linked constraints](#context-of-linked-constraints) + - [Validation groups of linked constraints](#validation-groups-of-linked-constraints) - [Cascade](#cascade) -- [Groups](#groups) -- [Group Sequences](#group-sequences) -- [Validating inside resolvers](#validating-inside-resolvers) -- [Injecting errors](#injecting-errors) -- [Error messages](#error-messages) - - [Customizing the response](#customizing-the-response) -- [Translations](#translations) -- [Using built-in expression functions](#using-built-in-expression-functions) -- [ValidationNode API](#validationnode-api) -- [Limitations](#limitations) + - [Example:](#example-1) + - [Groups](#groups) + - [Group Sequences](#group-sequences) + - [Validating inside resolvers](#validating-inside-resolvers) + - [Injecting `errors`](#injecting-errors) + - [Error Messages](#error-messages) + - [Customizing the response](#customizing-the-response) + - [Translations](#translations) + - [Using built-in expression functions](#using-built-in-expression-functions) + - [ValidationNode API](#validationnode-api) + - [Methods](#methods) + - [Examples](#examples) + - [Usage in the `Expression` constraints:](#usage-in-the-expression-constraints) + - [Usage with `Callback` constraints:](#usage-with-callback-constraints) + - [Limitations](#limitations) - [Annotations and GraphQL Schema language](#annotations-and-graphql-schema-language) - [Unsupported/Irrelevant constraints](#unsupportedirrelevant-constraints) ## Overview -In order to validate input data, the only thing you need to do is to apply [constraints](https://symfony.com/doc/current/reference/constraints.html) -in your `yaml` type definitions (`args` by `object` types and `fields` by `input-object` types). The bundle will then -automatically validate the data and throw an exception, which will be caught and returned in the response back to the +In order to validate input data, the only thing you need to do is to apply [constraints](https://symfony.com/doc/current/reference/constraints.html) +in your `yaml` type definitions (`args` by `object` types and `fields` by `input-object` types). The bundle will then +automatically validate the data and throw an exception, which will be caught and returned in the response back to the client. -Follow the example below to get a quick overview of the most basic validation capabilities of this bundle. +Follow the example below to get a quick overview of the most basic validation capabilities of this bundle. ```yaml # config\graphql\types\Mutation.yaml Mutation: @@ -57,11 +65,12 @@ Mutation: type: String! validation: # applying constraints to `password` - Length: + exactly: null # for Symfony 8+ due to validation constructor changes it is required to define in configs constructor parameters in corresponding order, even with default values min: 8 max: 32 - IdenticalTo: propertyPath: passwordRepeat - passwordRepeat: + passwordRepeat: type: String! emails: type: "[String]" @@ -75,7 +84,7 @@ Mutation: birthdate: type: Birthdate validation: cascade # delegating validation to the embedded type - + Birthdate: type: input-object config: @@ -89,14 +98,14 @@ Birthdate: validation: - Range: { min: 1, max: 12 } year: - type: Int! + type: Int! validation: - Range: { min: 1900, max: 2019 } ``` The configuration above checks, that: -- **username** +- **username** - has length between 6 and 32 -- **password** +- **password** - has length between 8 and 32 - is equal to the *passwordRepeat* value - **emails** @@ -112,42 +121,42 @@ The `birthdate` field is of type `input-object` and is marked as `cascade`, so i ## How does it work? -The [Symfony Validator Component](https://symfony.com/doc/current/components/validator.html) is designed to validate -objects. For this reason, when this bundle starts a validation, all input data is first converted into objects of class -[`ValidationNode`](#validationnode-api) and then validated. This process is performed -automatically by the bundle just **before** calling corresponding resolvers (each resolver gets its own `InputValidator` +The [Symfony Validator Component](https://symfony.com/doc/current/components/validator.html) is designed to validate +objects. For this reason, when this bundle starts a validation, all input data is first converted into objects of class +[`ValidationNode`](#validationnode-api) and then validated. This process is performed +automatically by the bundle just **before** calling corresponding resolvers (each resolver gets its own `InputValidator` instance). If validation fails, the corresponding resolver will not be called (except when you perform [validation inside your resolvers](#validating-inside-resolvers)). - -> Note that the created objects are only used for validation purposes. Your resolvers will receive raw unaltered + +> Note that the created objects are only used for validation purposes. Your resolvers will receive raw unaltered > arguments as usual. Validation objects are created differently depending on the GraphQL type. Take a look at the following scheme: ![enter_description](img/schema_1.png) -As you can see, there are 2 GraphQL types: **Mutation** and **DateInput** (`object` and `input-object` respectively). In -the case of **Mutation**, this bundle creates an object **per each field** (`createUser` and `createPost`), but in the -case of the **DateInput**, it creates an object for the entire type. +As you can see, there are 2 GraphQL types: **Mutation** and **DateInput** (`object` and `input-object` respectively). In +the case of **Mutation**, this bundle creates an object **per each field** (`createUser` and `createPost`), but in the +case of the **DateInput**, it creates an object for the entire type. Keep in mind that objects are not created recursively by default. As you can see, the argument `createdAt` has its -validation set to `cascade`. It is a special value, which delegates the validation to the embedded type by doing the +validation set to `cascade`. It is a special value, which delegates the validation to the embedded type by doing the following: - convert the subtype (`DateInput`) into an object. - embed the resulting object into its parent, making it a sub-object. - apply to it the [`Valid`](https://symfony.com/doc/current/reference/constraints/Valid.html) constraint (for a - recursive validation). - -If you don't mark embedded types as `cascade`, they will stay arrays, which can still be validated, as shown in the + recursive validation). + +If you don't mark embedded types as `cascade`, they will stay arrays, which can still be validated, as shown in the following examples. -All object properties are created dynamically and then the validation constraints are applied to them. The resulting +All object properties are created dynamically and then the validation constraints are applied to them. The resulting object composition will then be recursively validated, starting from the root object down to its children. -> **Note**: +> **Note**: > Although it would have been possible to validate raw arguments, objects provide a better flexibility and more features. -Here is a more complex example to better demonstrate how the `InputValidator` creates objects from your GraphQL schema +Here is a more complex example to better demonstrate how the `InputValidator` creates objects from your GraphQL schema and embeds them into each other: ```yaml Mutation: @@ -199,7 +208,7 @@ Mutation: - Length: { min: 2, max: 64 } zip: - Positive: ~ - + Job: type: input-object config: @@ -232,8 +241,8 @@ Address: zip: type: Int! validation: - - Positive: ~ - + - Positive: ~ + Period: type: input-object config: @@ -247,7 +256,7 @@ Period: validation: - Date: ~ - GreaterThan: - propertyPath: 'startDate' + propertyPath: 'startDate' Birthday: type: input-object @@ -262,9 +271,9 @@ Birthday: validation: - Range: { min: 1, max: 12 } year: - type: Int! + type: Int! validation: - - Range: { min: 1900, max: today } + - Range: { min: 1900, max: today } ``` The configuration above would produce an object composition as shown in the UML diagram below: @@ -276,9 +285,9 @@ The configuration above would produce an object composition as shown in the UML ## Applying validation constraints -If you are familiar with the Symfony Validator Component, you might know that constraints can have different -[targets](https://symfony.com/doc/current/validation.html#constraint-targets) (class members or entire classes). Since -all input data is represented by objects during the validation, you can also declare member constraints as well as class +If you are familiar with the Symfony Validator Component, you might know that constraints can have different +[targets](https://symfony.com/doc/current/validation.html#constraint-targets) (class members or entire classes). Since +all input data is represented by objects during the validation, you can also declare member constraints as well as class constraints. There are 3 different methods to apply validation constraints: @@ -290,7 +299,7 @@ All 3 methods can be mixed, but if you use only 1 method you can omit the corres under `validation`. ### Listing constraints directly -The most straightforward way to apply validation constraints is to list them under the `constraints` key. In the chapter +The most straightforward way to apply validation constraints is to list them under the `constraints` key. In the chapter [Overview](#overview) this method has already been demonstrated. Follow the examples below to see how to use _only_ this method, as well as in combinations with [linking](#linking-to-class-constraints): @@ -308,13 +317,13 @@ Mutation: username: type: String validation: # using an explicit list of constraints (short form) - - NotBlank: ~ + - NotBlank: ~ - Length: min: 6 max: 32 minMessage: "Username must have {{ limit }} characters or more" maxMessage: "Username must have {{ limit }} characters or less" - + email: type: String validation: App\Entity\User::$email # using a link (short form) @@ -338,9 +347,9 @@ Mutation: type: User resolve: "@=mutation('updateUser', [args])" args: - username: String + username: String email: String - info: String + info: String ``` It's also possible to declare validation constraints for the entire _type_. This is useful if you don't want to repeat the configuration for each field or if you want to move the entire validation logic into a function: ```yaml @@ -354,17 +363,17 @@ Mutation: type: User resolve: "@=mutation('createUser', [args])" args: - username: String + username: String email: String info: String updateUser: type: User resolve: "@=mutation('updateUser', [args])" args: - username: String + username: String email: String info: String - + ``` which is equal to: ```yaml @@ -378,7 +387,7 @@ Mutation: type: User resolve: "@=mutation('createUser', [args])" args: - username: String + username: String email: String info: String updateUser: @@ -387,16 +396,16 @@ Mutation: type: User resolve: "@=mutation('updateUser', [args])" args: - username: String + username: String email: String info: String - + ``` #### input-object: -`input-object` types are designed to be used as arguments in other types. Basically, they are composite arguments, so -the *property* constraints are declared for each _field_ unlike `object` types, where the property constraints are +`input-object` types are designed to be used as arguments in other types. Basically, they are composite arguments, so +the *property* constraints are declared for each _field_ unlike `object` types, where the property constraints are declared for each _argument_: ```yaml User: @@ -443,19 +452,19 @@ A `link` can have 4 different forms, each of which targets different parts of a - **class**: `` - the absence of a class member indicates an entire class. for example: - - **property**: `App\Entity\User::$username` - copies constraints of the property `$username` of the class `User`. + - **property**: `App\Entity\User::$username` - copies constraints of the property `$username` of the class `User`. - **getters**: `App\Entity\User::username()` - copies constraints of the getters `getUsername()`, `isUsername()` and `hasUsername()`. - **property and getters**: `App\Entity\User::username` - copies constraints of the property `$username` and its getters `getUsername()`, `isUsername()` and `hasUsername()`. - **class**: `App\Entity\User` - copies constraints applied to the entire class `User`. > **Note**: > If you target only getters, then prefixes must be omitted. For example, if you want to target getters of the class `User` with the names `isChild()` and `hasChildren()`, then the link would be `App\Entity\User::child()`. -> +> > Only getters with the prefix `get`, `has`, and `is` will be searched. > **Note**: -> Linked constraints which work in a context (e.g. Expression or Callback) will NOT copy the context of the linked ->class, but instead will work in its own context. That means that the `this` variable won't point to the linked class +> Linked constraints which work in a context (e.g. Expression or Callback) will NOT copy the context of the linked +>class, but instead will work in its own context. That means that the `this` variable won't point to the linked class >instance, but will point to an object of the class `ValidationNode` representing your input data. See the [How does it work?](#how-does-it-work) section for more details about internal work of the validation process. #### Example: @@ -468,18 +477,18 @@ use Symfony\Component\Validator\Constraints as Assert; /** * @Assert\Callback({"App\Validation\PostValidator", "validate"}) */ -class Post +class Post { /** * @Assert\NotBlank() */ private $title; - + /** * @Assert\Length(max=512) */ private $text; - + /** * @Assert\Length(min=5, max=10) */ @@ -487,7 +496,7 @@ class Post { return $this->title; } - + /** * @Assert\EqualTo("Lorem Ipsum") */ @@ -495,7 +504,7 @@ class Post { return strlen($this->title) !== 0; } - + /** * @Assert\Json() */ @@ -539,13 +548,13 @@ or use the short form (omitting the `link` key), which is equal to the config ab validation: App\Entity\Post::$text # only property # ... ``` -The argument `title` will get 3 assertions: `NotBlank()`, `Length(min=5, max=10)` and `EqualTo("Lorem Ipsum")`, whereas -the argument `text` will only get `Length(max=512)`. The method `validate` of the class `PostValidator` will also be +The argument `title` will get 3 assertions: `NotBlank()`, `Length(min=5, max=10)` and `EqualTo("Lorem Ipsum")`, whereas +the argument `text` will only get `Length(max=512)`. The method `validate` of the class `PostValidator` will also be called once, given an object representing the input data. #### Context of linked constraints -When linking constraints, keep in mind that the validation context won't be inherited (copied). For example, suppose you +When linking constraints, keep in mind that the validation context won't be inherited (copied). For example, suppose you have the following Doctrine entity: ```php @@ -554,9 +563,9 @@ namespace App\Entity; /** * @Assert\Callback("validate") */ -class User +class User { - public static function validate() + public static function validate() { // ... } @@ -573,21 +582,21 @@ Mutation: resolve: "@=res('createUser', [args])" # ... ``` -Now, when you try to validate the arguments in your resolver, it will throw an exception, because it will try to call a -method with the name `validate` on the object of class `ValidationNode`, which doesn't have such. As explained in the -section [How does it work?](#how-does-it-work) all input data is represented objects of class `ValidationNode` during +Now, when you try to validate the arguments in your resolver, it will throw an exception, because it will try to call a +method with the name `validate` on the object of class `ValidationNode`, which doesn't have such. As explained in the +section [How does it work?](#how-does-it-work) all input data is represented objects of class `ValidationNode` during the validation process. #### Validation groups of linked constraints -Linked constraints will be used _as it is_. This means that it's not possible to change any of their params including -_groups_. For example, if you link a _property_ on class `User`, then all copied constraints will be in the groups +Linked constraints will be used _as it is_. This means that it's not possible to change any of their params including +_groups_. For example, if you link a _property_ on class `User`, then all copied constraints will be in the groups `Default` and `User` (unless other groups declared explicitly in the linked class itself). ### Cascade -The validation of arguments of the type `input-object`, which are marked as `cascade`, will be delegated to the embedded +The validation of arguments of the type `input-object`, which are marked as `cascade`, will be delegated to the embedded type. The nesting can be any depth. #### Example: @@ -600,7 +609,7 @@ Mutation: type: Post resolve: "@=mutation('update_user', [args])" args: - id: + id: type: ID! address: type: AddressInput @@ -644,22 +653,22 @@ PeriodInput: ## Groups -It is possible to organize constraints into [validation groups](https://symfony.com/doc/current/validation/groups.html). -By default, if you don't declare groups explicitly, every constraint of your type will be in 2 groups: **Default** and +It is possible to organize constraints into [validation groups](https://symfony.com/doc/current/validation/groups.html). +By default, if you don't declare groups explicitly, every constraint of your type will be in 2 groups: **Default** and the name of the type. For example, if the type's name is **Mutation** and the declaration of constraint is `NotBlank: ~` - (no explicit groups declared), then it automatically falls into 2 default groups: **Default** and **Mutation**. These - default groups will be removed, if you declare groups explicitly. Follow the - [link](https://symfony.com/doc/current/validation/groups.html) for more details about validation groups in the Symfony + (no explicit groups declared), then it automatically falls into 2 default groups: **Default** and **Mutation**. These + default groups will be removed, if you declare groups explicitly. Follow the + [link](https://symfony.com/doc/current/validation/groups.html) for more details about validation groups in the Symfony Validator Component. -Validation groups could be useful if you use a same `input-object` type in different contexts and want it to be +Validation groups could be useful if you use a same `input-object` type in different contexts and want it to be validated differently (with different groups). Take a look at the following example: ```yaml Mutation: type: object config: fields: - registerUser: + registerUser: type: User resolve: "@=mut('register_user')" validationGroups: ['User'] @@ -667,7 +676,7 @@ Mutation: input: type: UserInput! validation: cascade - registerAdmin: + registerAdmin: type: User resolve: "@=mut('register_admin')" validationGroups: ['Admin'] @@ -690,15 +699,15 @@ UserInput: - Length: {min: 4, max: 32, groups: 'User'} - Length: {min: 10, max: 32, groups: 'Admin'} ``` -As you can see the `password` field of the `UserInput` type has a same constraint applied to it twice, but with -different groups. The `validationGroups` option ensures that validation will only use the constraints that are listed +As you can see the `password` field of the `UserInput` type has a same constraint applied to it twice, but with +different groups. The `validationGroups` option ensures that validation will only use the constraints that are listed in it. In case you inject the validator into the resolver (as described [here](#validating-inside-resolvers)), the `validationGroups` -option will be ignored. Instead you should pass groups directly to the injected validator. This approach could be +option will be ignored. Instead you should pass groups directly to the injected validator. This approach could be necessary in some few cases. -Let's take the example from the chapter [Overview](#overview) and edit the configuration to inject the `validator` and +Let's take the example from the chapter [Overview](#overview) and edit the configuration to inject the `validator` and to use validation groups: ```yaml # config\graphql\types\Mutation.yaml @@ -726,7 +735,7 @@ Mutation: - IdenticalTo: propertyPath: passwordRepeat groups: ['registration'] - passwordRepeat: + passwordRepeat: type: String! emails: type: "[String]" @@ -740,7 +749,7 @@ Mutation: birthday: type: Birthday validation: cascade - + Birthday: type: input-object config: @@ -754,12 +763,12 @@ Birthday: validation: - Range: { min: 1, max: 12 } year: - type: Int! + type: Int! validation: - Range: { min: 1900, max: today } ``` -Here we injected the `validator` variable into the `register` resolver. By doing so we are turning the automatic -validation off to perform it inside the resolver (see [Validating inside resolvers](#validating-inside-resolvers)). The +Here we injected the `validator` variable into the `register` resolver. By doing so we are turning the automatic +validation off to perform it inside the resolver (see [Validating inside resolvers](#validating-inside-resolvers)). The injected instance of the `InputValidator` class could be used in a resolver as follows: ```php namespace App\GraphQL\Mutation\Mutation @@ -774,14 +783,14 @@ class UserResolver implements MutationInterface, AliasedInterface public function register(Argument $args, InputValidator $validator) { - /* + /* * Validates: * - username against 'Length' * - password against 'IdenticalTo' */ $validator->validate('registration'); - - /* + + /* * Validates: * - password against 'Length' * - emails against 'Unique', 'Count' and 'All' @@ -789,17 +798,17 @@ class UserResolver implements MutationInterface, AliasedInterface * - day against 'Range' * - month against 'Range' * - year against 'Range' - */ + */ $validator->validate('Default'); // ... which is in this case equal to: - $validator->validate(); - - /** - * Validates only arguments in the 'Birthday' type + $validator->validate(); + + /** + * Validates only arguments in the 'Birthday' type * against constraints with no explicit groups. */ - $validator->validate('Birthdate'); - + $validator->validate('Birthdate'); + // Validates all arguments in each type against all constraints. $validator->validate(['registration', 'Default']); // ... which is in this case equal to: @@ -809,7 +818,7 @@ class UserResolver implements MutationInterface, AliasedInterface public static function getAliases(): array { return ['register' => 'register']; - } + } } ``` > **Note**: @@ -848,8 +857,8 @@ Mutation: ``` ## Validating inside resolvers -You can turn the auto-validation off by injecting the validator into your resolver. This can be useful if you want to -do something before the actual validation happens or customize other aspects, for example validate data multiple times +You can turn the auto-validation off by injecting the validator into your resolver. This can be useful if you want to +do something before the actual validation happens or customize other aspects, for example validate data multiple times with different groups or make the validation conditional. Here is how you can inject the validator: @@ -875,7 +884,7 @@ class UserResolver implements MutationInterface, AliasedInterface { public function register(Argument $args, InputValidator $validator): User { - // This line executes a validation process and throws ArgumentsValidationException + // This line executes a validation process and throws ArgumentsValidationException // on fail. The client will then get a well formatted error message. $validator->validate(); @@ -885,21 +894,21 @@ class UserResolver implements MutationInterface, AliasedInterface // Or use a short syntax, which is equal to $validator->validate(). // This is possible thanks to the __invoke magic method. $validator(); - + // The code below won't be reached if one of the validations above fails $user = $this->userManager->createUser($args); $this->userManager->save($user); - + return $user; } public static function getAliases(): array { return ['register' => 'register']; - } + } } ``` -If you want to prevent the validator to automatically throw an exception just pass `false` as the second argument. It +If you want to prevent the validator to automatically throw an exception just pass `false` as the second argument. It will return an instance of the `ConstraintViolationList` class instead: ```php $errors = $validator->validate('my_group', false); @@ -934,22 +943,22 @@ class UserResolver implements MutationInterface, AliasedInterface public function register(Argument $args, ResolveErrors $errors): User { $violations = $errors->getValidationErrors(); - + // ... } public static function getAliases(): array { return ['register' => 'register']; - } + } } ``` ## Error Messages -By default the `InputValidator` throws an `ArgumentsValidationException`, which will be caught and serialized into -a readable response. The [GraphQL specification](https://graphql.github.io/graphql-spec/June2018/#sec-Errors) defines a +By default the `InputValidator` throws an `ArgumentsValidationException`, which will be caught and serialized into +a readable response. The [GraphQL specification](https://graphql.github.io/graphql-spec/June2018/#sec-Errors) defines a certain shape of all errors returned in the response. According to it all validation violations are to be found under -the path `errors[index].extensions.validation` of the response object. +the path `errors[index].extensions.validation` of the response object. Example of a response with validation errors: @@ -963,17 +972,17 @@ Example of a response with validation errors: "validation": { "username": [ { - "message": "This value should be equal to 'Lorem Ipsum'.", + "message": "This value should be equal to 'Lorem Ipsum'.", "code": "478618a7-95ba-473d-9101-cabd45e49115" } ], "email": [ { - "message": "This value is not a valid email address.", + "message": "This value is not a valid email address.", "code": "bd79c0ab-ddba-46cc-a703-a7a4b08de310" }, { - "message": "This value is too short. It should have 5 character or more.", + "message": "This value is too short. It should have 5 character or more.", "code": "9ff3fdc4-b214-49db-8718-39c315e33d45" } ] @@ -990,14 +999,14 @@ Example of a response with validation errors: The codes in the response could be used to perform a client-side translation of the validation violations. ### Customizing the response -You can customize the output by passing `false` as a second argument to the `validate` method. +You can customize the output by passing `false` as a second argument to the `validate` method. This will prevent an exception to be thrown and a `ConstraintViolationList` object will be returned instead: ```php -public function resolver(InputValidator $validator) +public function resolver(InputValidator $validator) { $errors = $validator->validate(null, false); - + // Use $errors to build your own exception ... } @@ -1006,7 +1015,7 @@ See more about [Error handling](https://github.com/overblog/GraphQLBundle/blob/m ## Translations -All validation violations are automatically translated from the `validators` domain. +All validation violations are automatically translated from the `validators` domain. Example: ```yaml @@ -1029,7 +1038,7 @@ Mutation: password: type: String! validation: - - Length: + - Length: min: 8 max: 32 minMessage: "register.password.length.min" @@ -1037,7 +1046,7 @@ Mutation: - IdenticalTo: propertyPath: passwordRepeat message: "register.password.identical" - passwordRepeat: + passwordRepeat: type: String! ``` @@ -1068,19 +1077,19 @@ To translate into other languages just create additional translation resource w ## Using built-in expression functions -This bundle comes with a built-in [ExpressionLanguage](https://symfony.com/doc/current/components/expression_language.html) -instance and pre-registered [expression functions and variables](https://github.com/overblog/GraphQLBundle/blob/master/docs/definitions/expression-language.md). -By default the [`Expression`](https://symfony.com/doc/current/reference/constraints/Expression.html) -constraint in your project has no access to these functions and variables, because it uses the default instance of the +This bundle comes with a built-in [ExpressionLanguage](https://symfony.com/doc/current/components/expression_language.html) +instance and pre-registered [expression functions and variables](https://github.com/overblog/GraphQLBundle/blob/master/docs/definitions/expression-language.md). +By default the [`Expression`](https://symfony.com/doc/current/reference/constraints/Expression.html) +constraint in your project has no access to these functions and variables, because it uses the default instance of the `ExpressionLanguage` class. In order to _tell_ the `Expression` constraint to use the instance of this bundle, you need to rewrite its service declaration. Add the following config to the `services.yaml`: ```yaml -validator.expression: - class: Overblog\GraphQLBundle\Validator\Constraints\ExpressionValidator - arguments: ['@Overblog\GraphQLBundle\ExpressionLanguage\ExpressionLanguage'] - tags: - - name: validator.constraint_validator +validator.expression: + class: Overblog\GraphQLBundle\Validator\Constraints\ExpressionValidator + arguments: ['@Overblog\GraphQLBundle\ExpressionLanguage\ExpressionLanguage'] + tags: + - name: validator.constraint_validator alias: validator.expression ``` @@ -1105,8 +1114,8 @@ args: > **Note**: > > Expressions in the `Expression` constraint should NOT be prefixed with `@=`. -> As you might know, the `Expression` constraint has one built-in variable called [`value`](https://symfony.com/doc/current/reference/constraints/Expression.html#message). ->In order to avoid name conflicts, the resolver variable `value` is renamed to `parentValue`, when using in the +> As you might know, the `Expression` constraint has one built-in variable called [`value`](https://symfony.com/doc/current/reference/constraints/Expression.html#message). +>In order to avoid name conflicts, the resolver variable `value` is renamed to `parentValue`, when using in the >`Expression` constraint. > > In short: the `value` represents currently validated input data, and `parentValue` represents the data returned by the parent resolver. @@ -1119,24 +1128,24 @@ The ValidationNode class is used internally during the validation process. See t This class has methods that may be useful when using such constraints as `Callback` or `Expression`, which work in a context. ### Methods -getType(): GraphQL\Type\Definition\Type +getType(): GraphQL\Type\Definition\Type   Returns the `Type` object associated with current validation node. -getName(): string +getName(): string   Returns the name of the associated Type object. Shorthand for `getType()->name`. -getFieldName(): string|null -  Returns the field name if the object is associated with an `object` type, otherwise returns `null` +getFieldName(): string|null +  Returns the field name if the object is associated with an `object` type, otherwise returns `null` -getParent(): ValidationNode|null +getParent(): ValidationNode|null   Returns the parent node. -findParent(string $name): ValidationNode|null +findParent(string $name): ValidationNode|null   Traverses up through parent nodes and returns first object with matching name. ### Examples -#### Usage in the `Expression` constraints: +#### Usage in the `Expression` constraints: In this example we are checking if the value of the field `shownEmail` is contained in the `emails` array. We are using the method `getParent()` to access a field of the type `Mutation` from within the type `Profile`: ```yaml Mutation: @@ -1162,7 +1171,7 @@ Mutation: profile: type: Profile validation: cascade - + Profile: type: input-object config: @@ -1203,24 +1212,24 @@ Mutation: To find out which of 2 fields is being validated inside the method, we can use method `getFieldName`: ```php -namespace App\Validation; +namespace App\Validation; use Overblog\GraphQLBundle\Validator\ValidationNode; // ... public static function validate(ValidationNode $object, ExecutionContextInterface $context, $payload): void - { - switch ($object->getFieldName()) { - case 'createUser': - // Validation logic for users - break; - case 'createAdmin': - // Validation logic for admins - break; - default: - // Validation logic for all other fields - } + { + switch ($object->getFieldName()) { + case 'createUser': + // Validation logic for users + break; + case 'createAdmin': + // Validation logic for admins + break; + default: + // Validation logic for all other fields + } } // ... @@ -1239,6 +1248,6 @@ These are the validation constraints, which are not currently supported or have - [File](https://symfony.com/doc/current/reference/constraints/File.html) - not supported (_under development_) - [Image](https://symfony.com/doc/current/reference/constraints/Image.html) - not supported (_under development_) - [UniqueEntity](https://symfony.com/doc/current/reference/constraints/UniqueEntity.html) -- [Traverse](https://symfony.com/doc/current/reference/constraints/Traverse.html) - although you can use this constraint, -it would make no sense, as nested objects will be automatically validated with the `Valid` +- [Traverse](https://symfony.com/doc/current/reference/constraints/Traverse.html) - although you can use this constraint, +it would make no sense, as nested objects will be automatically validated with the `Valid` constraint. See [How does it work?](#how-does-it-work) section to get familiar with the internals. diff --git a/source b/source new file mode 100644 index 000000000..44af5390a --- /dev/null +++ b/source @@ -0,0 +1,279 @@ + self::NAME, + 'validation' => fn() => [ + new SymfonyConstraints\Callback(['Overblog\\GraphQLBundle\\Tests\\Functional\\Validator\\StaticValidator', 'alwaysTrue']), + new SymfonyConstraints\Expression('this.getFieldName() == this.getFieldName()'), + ], + 'fields' => fn() => [ + 'noValidation' => [ + 'type' => Type::boolean(), + 'resolve' => function ($value, $args, $context, $info) use ($services) { + return $services->mutation("no_validation"); + }, + 'args' => [ + [ + 'name' => 'username', + 'type' => Type::nonNull(Type::string()), + 'defaultValue' => 'Frank', + ], + ], + 'complexity' => fn() => 0, + 'public' => true, + ], + 'simpleValidation' => [ + 'type' => Type::boolean(), + 'validation' => fn() => [ + new SymfonyConstraints\Expression('this.getFieldName() == this.getFieldName()'), + ], + 'resolve' => function ($value, $args, $context, $info) use ($services) { + $validator = $services->createInputValidator(...func_get_args()); + return $services->mutation("mutation_mock", $args, $validator); + }, + 'args' => [ + [ + 'name' => 'username', + 'type' => Type::nonNull(Type::string()), + 'validation' => fn() => [ + new SymfonyConstraints\Length(null, 5), + new SymfonyConstraints\Regex('/^[a-z]+$/i'), + ], + ], + ], + ], + 'linkedConstraintsValidation' => [ + 'type' => Type::boolean(), + 'validation' => [ + 'link' => 'Overblog\\GraphQLBundle\\Tests\\Functional\\Validator\\DummyEntity', + 'constraints' => fn() => [ + new SymfonyConstraints\Expression('this.string2 == \'Dolor Sit Amet\''), + ], + ], + 'resolve' => function ($value, $args, $context, $info) use ($services) { + $validator = $services->createInputValidator(...func_get_args()); + return $services->mutation("mutation_mock", $args, $validator); + }, + 'args' => [ + [ + 'name' => 'string1', + 'type' => Type::nonNull(Type::string()), + 'validation' => [ + 'link' => ['Overblog\\GraphQLBundle\\Tests\\Functional\\Validator\\DummyEntity', 'string1', 'property'], + ], + ], + [ + 'name' => 'string2', + 'type' => Type::nonNull(Type::string()), + 'validation' => [ + 'link' => ['Overblog\\GraphQLBundle\\Tests\\Functional\\Validator\\DummyEntity', 'string2', 'getter'], + ], + ], + [ + 'name' => 'string3', + 'type' => Type::nonNull(Type::string()), + 'validation' => [ + 'link' => ['Overblog\\GraphQLBundle\\Tests\\Functional\\Validator\\DummyEntity', 'string3', 'member'], + ], + ], + ], + ], + 'collectionValidation' => [ + 'type' => Type::boolean(), + 'resolve' => function ($value, $args, $context, $info) use ($services) { + $validator = $services->createInputValidator(...func_get_args()); + return $services->mutation("mutation_mock", $args, $validator); + }, + 'args' => [ + [ + 'name' => 'addresses', + 'type' => fn() => Type::nonNull(Type::listOf($services->getType('Address'))), + 'validation' => InputValidator::CASCADE, + ], + [ + 'name' => 'emails', + 'type' => Type::nonNull(Type::listOf(Type::string())), + 'validation' => fn() => [ + new SymfonyConstraints\Unique(), + new SymfonyConstraints\Count(null, 3), + new SymfonyConstraints\All([ + new SymfonyConstraints\Email(null, 'The email "{{ value }}" is not a valid email.'), + ]), + ], + ], + ], + ], + 'cascadeValidationWithGroups' => [ + 'type' => Type::boolean(), + 'resolve' => function ($value, $args, $context, $info) use ($services) { + $validator = $services->createInputValidator(...func_get_args()); + return $services->mutation("mutation_mock", $args, $validator); + }, + 'args' => [ + [ + 'name' => 'groups', + 'type' => Type::nonNull(Type::listOf(Type::string())), + ], + [ + 'name' => 'address', + 'type' => fn() => Type::nonNull($services->getType('Address')), + 'validation' => InputValidator::CASCADE, + ], + [ + 'name' => 'birthdate', + 'type' => fn() => $services->getType('Birthdate'), + 'validation' => [ + 'cascade' => ['group2'], + ], + ], + ], + ], + 'userPasswordValidation' => [ + 'type' => Type::boolean(), + 'resolve' => function ($value, $args, $context, $info) use ($services) { + $validator = $services->createInputValidator(...func_get_args()); + return $services->mutation("mutation_mock", $args, $validator); + }, + 'args' => [ + [ + 'name' => 'oldPassword', + 'type' => Type::string(), + 'validation' => fn() => [ + new \Symfony\Component\Security\Core\Validator\Constraints\UserPassword(), + ], + ], + ], + ], + 'expressionVariablesValidation' => [ + 'type' => Type::boolean(), + 'resolve' => function ($value, $args, $context, $info) use ($services) { + $validator = $services->createInputValidator(...func_get_args()); + return $services->mutation("mutation_mock", $args, $validator); + }, + 'args' => [ + [ + 'name' => 'username', + 'type' => Type::string(), + 'validation' => fn() => [ + new SymfonyConstraints\Expression('service_validator.resolveVariablesAccessible(args, info)'), + ], + ], + ], + ], + 'autoValidationAutoThrow' => [ + 'type' => Type::boolean(), + 'resolve' => function ($value, $args, $context, $info) use ($services) { + $validator = $services->createInputValidator(...func_get_args()); + + $validator->validate(null); + + return $services->mutation("mutation_mock", $args); + }, + 'args' => [ + [ + 'name' => 'username', + 'type' => Type::nonNull(Type::string()), + 'validation' => fn() => [ + new SymfonyConstraints\Length(null, 5), + new SymfonyConstraints\Regex('/^[a-z]+$/i'), + ], + ], + ], + ], + 'autoValidationNoThrow' => [ + 'type' => Type::boolean(), + 'resolve' => function ($value, $args, $context, $info) use ($services) { + $errors = new ResolveErrors(); + $validator = $services->createInputValidator(...func_get_args()); + + $errors->setValidationErrors($validator->validate(null, false)); + + return $services->mutation("mutation_errors", $errors); + }, + 'args' => [ + [ + 'name' => 'username', + 'type' => Type::nonNull(Type::string()), + 'validation' => fn() => [ + new SymfonyConstraints\Length(null, 5), + new SymfonyConstraints\Regex('/^[a-z]+$/i'), + ], + ], + ], + ], + 'autoValidationAutoThrowWithGroups' => [ + 'type' => Type::boolean(), + 'resolve' => function ($value, $args, $context, $info) use ($services) { + $validator = $services->createInputValidator(...func_get_args()); + + $validator->validate(['Default', 'Address', 'Period', 'group1', 'group2']); + + return $services->mutation("mutation_mock", $args); + }, + 'args' => [ + [ + 'name' => 'address', + 'type' => fn() => Type::nonNull($services->getType('Address')), + 'validation' => InputValidator::CASCADE, + ], + [ + 'name' => 'birthdate', + 'type' => fn() => $services->getType('Birthdate'), + 'validation' => [ + 'cascade' => ['group2'], + ], + ], + ], + ], + 'partialInputObjectsCollectionValidation' => [ + 'type' => Type::boolean(), + 'resolve' => function ($value, $args, $context, $info) use ($services) { + $validator = $services->createInputValidator(...func_get_args()); + + $validator->validate(null); + + return $services->mutation("mutation_mock", $args); + }, + 'args' => [ + [ + 'name' => 'addresses', + 'type' => fn() => Type::listOf($services->getType('Address')), + 'validation' => InputValidator::CASCADE, + ], + ], + ], + ], + ]; + + parent::__construct($configProcessor->process($config)); + } + + /** + * {@inheritdoc} + */ + public static function getAliases(): array + { + return [self::NAME]; + } +} \ No newline at end of file diff --git a/src/Generator/TypeBuilder.php b/src/Generator/TypeBuilder.php index 6efb9c88c..b948c8350 100644 --- a/src/Generator/TypeBuilder.php +++ b/src/Generator/TypeBuilder.php @@ -635,10 +635,34 @@ private function buildConstraints(array $constraints = [], bool $inClosure = tru } if (is_array($args)) { - if (isset($args[0]) && is_array($args[0])) { + $reflectionClass = new \ReflectionClass($fqcn); + $constructor = $reflectionClass->getConstructor(); + + $inlineParameters = false; + if ($constructor !== null) { + $parameterNames = []; + $parameters = $constructor->getParameters(); + foreach ($parameters as $parameter) { + $name = $parameter->getName(); + $parameterNames[] = $name; + } + + $checkedPosition = 0; + foreach ($args as $key => $value) { + if ( + isset($parameterNames[$checkedPosition]) === true + && $parameterNames[$checkedPosition++] === $key + ) { + $instance->addArgument($value); + $inlineParameters = true; + } + } + } + + if (isset($args[0]) && is_array($args[0]) && $inlineParameters === false) { // Nested instance $instance->addArgument($this->buildConstraints($args, false)); - } elseif (isset($args['constraints'][0]) && is_array($args['constraints'][0])) { + } elseif (isset($args['constraints'][0]) && is_array($args['constraints'][0]) && $inlineParameters === false) { // Nested instance with "constraints" key (full syntax) $options = [ 'constraints' => $this->buildConstraints($args['constraints'], false), @@ -653,7 +677,7 @@ private function buildConstraints(array $constraints = [], bool $inClosure = tru } $instance->addArgument($options); - } else { + } elseif ($inlineParameters === false) { // Numeric or Assoc array? $instance->addArgument(isset($args[0]) ? $args : Collection::assoc($args)); } diff --git a/tests/Functional/App/config/validator/mapping/Address.types.yml b/tests/Functional/App/config/validator/mapping/Address.types.yml index 1e3a6c343..e4baa02f2 100644 --- a/tests/Functional/App/config/validator/mapping/Address.types.yml +++ b/tests/Functional/App/config/validator/mapping/Address.types.yml @@ -7,13 +7,23 @@ Address: street: type: String! validation: - - Length: {min: 10} + - Length: { exactly: null, min: 10} city: type: String! validation: - Choice: - groups: ['group1'] + options: null choices: ['New York', 'Berlin', 'Tokyo'] + callback: null + multiple: null + strict: null + min: null + max: null + message: null + multipleMessage: null + minMessage: null + maxMessage: null + groups: ['group1'] country: type: Country validation: cascade diff --git a/tests/Functional/App/config/validator/mapping/Birthdate.types.yml b/tests/Functional/App/config/validator/mapping/Birthdate.types.yml index d7f928831..168b9f1e7 100644 --- a/tests/Functional/App/config/validator/mapping/Birthdate.types.yml +++ b/tests/Functional/App/config/validator/mapping/Birthdate.types.yml @@ -5,12 +5,41 @@ Birthdate: day: type: Int! validation: - - Range: { min: 1, max: 31, groups: ["group2"] } + - Range: + options: null + notInRangeMessage: null + minMessage: null + maxMessage: null + invalidMessage: null + invalidDateTimeMessage: null + min: 1 + minPropertyPath: null + max: 31 + maxPropertyPath: null + groups: ["group2"] month: type: Int! validation: - - Range: { min: 1, max: 12 } + - Range: + options: null + notInRangeMessage: null + minMessage: null + maxMessage: null + invalidMessage: null + invalidDateTimeMessage: null + min: 1 + minPropertyPath: null + max: 12 year: type: Int! validation: - - Range: { min: 1901, max: 2019 } + - Range: + options: null + notInRangeMessage: null + minMessage: null + maxMessage: null + invalidMessage: null + invalidDateTimeMessage: null + min: 1901 + minPropertyPath: null + max: 2019 diff --git a/tests/Functional/App/config/validator/mapping/Country.types.yml b/tests/Functional/App/config/validator/mapping/Country.types.yml index 09f2f266e..e26a0615a 100644 --- a/tests/Functional/App/config/validator/mapping/Country.types.yml +++ b/tests/Functional/App/config/validator/mapping/Country.types.yml @@ -6,8 +6,12 @@ Country: type: String validation: - NotBlank: + options: null + message: null allowNull: true officialLanguage: type: String validation: - - Choice: ['en', 'de', 'fr'] + - Choice: + options: null + choices: ['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 6c85fd67c..0a3b9b924 100644 --- a/tests/Functional/App/config/validator/mapping/Mutation.types.yml +++ b/tests/Functional/App/config/validator/mapping/Mutation.types.yml @@ -3,9 +3,7 @@ Mutation: config: validation: # Applied to all fields - Callback: [Overblog\GraphQLBundle\Tests\Functional\Validator\StaticValidator, alwaysTrue] - - Expression: - expression: this.getFieldName() == this.getFieldName() - message: "parent" + - Expression: this.getFieldName() == this.getFieldName() fields: noValidation: complexity: 1 @@ -20,16 +18,14 @@ Mutation: simpleValidation: validation: # Applied to all fields - - Expression: - expression: this.getFieldName() == this.getFieldName() - message: "child" + - Expression: this.getFieldName() == this.getFieldName() type: Boolean resolve: "@=m('mutation_mock', args, validator)" args: username: type: String! validation: - - Length: { min: 5 } + - Length: { exactly: null, min: 5 } - Regex: pattern: '/^[a-z]+$/i' htmlPattern: true @@ -63,9 +59,12 @@ Mutation: type: '[String]!' validation: - Unique: ~ - - Count: { min: 3 } + - Count: + exactly: null + min: 3 - All: - Email: + options: null message: 'The email "{{ value }}" is not a valid email.' cascadeValidationWithGroups: @@ -108,7 +107,7 @@ Mutation: username: type: String! validation: - - Length: { min: 5 } + - Length: { exactly: null, min: 5 } - Regex: pattern: '/^[a-z]+$/i' htmlPattern: true @@ -120,7 +119,7 @@ Mutation: username: type: String! validation: - - Length: { min: 5 } + - Length: { exactly: null, min: 5 } - Regex: pattern: '/^[a-z]+$/i' htmlPattern: true diff --git a/tests/Functional/App/config/validator/mapping/Period.types.yml b/tests/Functional/App/config/validator/mapping/Period.types.yml index 557506553..1b9c295b7 100644 --- a/tests/Functional/App/config/validator/mapping/Period.types.yml +++ b/tests/Functional/App/config/validator/mapping/Period.types.yml @@ -17,4 +17,5 @@ Period: - Expression: "this.getParent().getName() === 'Address'" - Date: ~ - GreaterThan: + value: null propertyPath: 'startDate' From ca65eb4006131996574907a954c54f9a620538b2 Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Wed, 28 Jan 2026 14:41:38 +0200 Subject: [PATCH 18/33] cq --- src/Generator/TypeBuilder.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Generator/TypeBuilder.php b/src/Generator/TypeBuilder.php index b948c8350..fc061a144 100644 --- a/src/Generator/TypeBuilder.php +++ b/src/Generator/TypeBuilder.php @@ -32,6 +32,7 @@ use Overblog\GraphQLBundle\Generator\Converter\ExpressionConverter; use Overblog\GraphQLBundle\Generator\Exception\GeneratorException; use Overblog\GraphQLBundle\Validator\InputValidator; +use ReflectionClass; use function array_map; use function class_exists; @@ -635,11 +636,11 @@ private function buildConstraints(array $constraints = [], bool $inClosure = tru } if (is_array($args)) { - $reflectionClass = new \ReflectionClass($fqcn); + $reflectionClass = new ReflectionClass($fqcn); $constructor = $reflectionClass->getConstructor(); $inlineParameters = false; - if ($constructor !== null) { + if (null !== $constructor) { $parameterNames = []; $parameters = $constructor->getParameters(); foreach ($parameters as $parameter) { @@ -659,10 +660,10 @@ private function buildConstraints(array $constraints = [], bool $inClosure = tru } } - if (isset($args[0]) && is_array($args[0]) && $inlineParameters === false) { + if (isset($args[0]) && is_array($args[0]) && false === $inlineParameters) { // Nested instance $instance->addArgument($this->buildConstraints($args, false)); - } elseif (isset($args['constraints'][0]) && is_array($args['constraints'][0]) && $inlineParameters === false) { + } elseif (isset($args['constraints'][0]) && is_array($args['constraints'][0]) && false === $inlineParameters) { // Nested instance with "constraints" key (full syntax) $options = [ 'constraints' => $this->buildConstraints($args['constraints'], false), @@ -677,7 +678,7 @@ private function buildConstraints(array $constraints = [], bool $inClosure = tru } $instance->addArgument($options); - } elseif ($inlineParameters === false) { + } elseif (false === $inlineParameters) { // Numeric or Assoc array? $instance->addArgument(isset($args[0]) ? $args : Collection::assoc($args)); } From 6b8fb8e9f3f838e64dc4fefc33aaeaeb35572324 Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Wed, 28 Jan 2026 14:42:20 +0200 Subject: [PATCH 19/33] cleanup --- source | 279 --------------------------------------------------------- 1 file changed, 279 deletions(-) delete mode 100644 source diff --git a/source b/source deleted file mode 100644 index 44af5390a..000000000 --- a/source +++ /dev/null @@ -1,279 +0,0 @@ - self::NAME, - 'validation' => fn() => [ - new SymfonyConstraints\Callback(['Overblog\\GraphQLBundle\\Tests\\Functional\\Validator\\StaticValidator', 'alwaysTrue']), - new SymfonyConstraints\Expression('this.getFieldName() == this.getFieldName()'), - ], - 'fields' => fn() => [ - 'noValidation' => [ - 'type' => Type::boolean(), - 'resolve' => function ($value, $args, $context, $info) use ($services) { - return $services->mutation("no_validation"); - }, - 'args' => [ - [ - 'name' => 'username', - 'type' => Type::nonNull(Type::string()), - 'defaultValue' => 'Frank', - ], - ], - 'complexity' => fn() => 0, - 'public' => true, - ], - 'simpleValidation' => [ - 'type' => Type::boolean(), - 'validation' => fn() => [ - new SymfonyConstraints\Expression('this.getFieldName() == this.getFieldName()'), - ], - 'resolve' => function ($value, $args, $context, $info) use ($services) { - $validator = $services->createInputValidator(...func_get_args()); - return $services->mutation("mutation_mock", $args, $validator); - }, - 'args' => [ - [ - 'name' => 'username', - 'type' => Type::nonNull(Type::string()), - 'validation' => fn() => [ - new SymfonyConstraints\Length(null, 5), - new SymfonyConstraints\Regex('/^[a-z]+$/i'), - ], - ], - ], - ], - 'linkedConstraintsValidation' => [ - 'type' => Type::boolean(), - 'validation' => [ - 'link' => 'Overblog\\GraphQLBundle\\Tests\\Functional\\Validator\\DummyEntity', - 'constraints' => fn() => [ - new SymfonyConstraints\Expression('this.string2 == \'Dolor Sit Amet\''), - ], - ], - 'resolve' => function ($value, $args, $context, $info) use ($services) { - $validator = $services->createInputValidator(...func_get_args()); - return $services->mutation("mutation_mock", $args, $validator); - }, - 'args' => [ - [ - 'name' => 'string1', - 'type' => Type::nonNull(Type::string()), - 'validation' => [ - 'link' => ['Overblog\\GraphQLBundle\\Tests\\Functional\\Validator\\DummyEntity', 'string1', 'property'], - ], - ], - [ - 'name' => 'string2', - 'type' => Type::nonNull(Type::string()), - 'validation' => [ - 'link' => ['Overblog\\GraphQLBundle\\Tests\\Functional\\Validator\\DummyEntity', 'string2', 'getter'], - ], - ], - [ - 'name' => 'string3', - 'type' => Type::nonNull(Type::string()), - 'validation' => [ - 'link' => ['Overblog\\GraphQLBundle\\Tests\\Functional\\Validator\\DummyEntity', 'string3', 'member'], - ], - ], - ], - ], - 'collectionValidation' => [ - 'type' => Type::boolean(), - 'resolve' => function ($value, $args, $context, $info) use ($services) { - $validator = $services->createInputValidator(...func_get_args()); - return $services->mutation("mutation_mock", $args, $validator); - }, - 'args' => [ - [ - 'name' => 'addresses', - 'type' => fn() => Type::nonNull(Type::listOf($services->getType('Address'))), - 'validation' => InputValidator::CASCADE, - ], - [ - 'name' => 'emails', - 'type' => Type::nonNull(Type::listOf(Type::string())), - 'validation' => fn() => [ - new SymfonyConstraints\Unique(), - new SymfonyConstraints\Count(null, 3), - new SymfonyConstraints\All([ - new SymfonyConstraints\Email(null, 'The email "{{ value }}" is not a valid email.'), - ]), - ], - ], - ], - ], - 'cascadeValidationWithGroups' => [ - 'type' => Type::boolean(), - 'resolve' => function ($value, $args, $context, $info) use ($services) { - $validator = $services->createInputValidator(...func_get_args()); - return $services->mutation("mutation_mock", $args, $validator); - }, - 'args' => [ - [ - 'name' => 'groups', - 'type' => Type::nonNull(Type::listOf(Type::string())), - ], - [ - 'name' => 'address', - 'type' => fn() => Type::nonNull($services->getType('Address')), - 'validation' => InputValidator::CASCADE, - ], - [ - 'name' => 'birthdate', - 'type' => fn() => $services->getType('Birthdate'), - 'validation' => [ - 'cascade' => ['group2'], - ], - ], - ], - ], - 'userPasswordValidation' => [ - 'type' => Type::boolean(), - 'resolve' => function ($value, $args, $context, $info) use ($services) { - $validator = $services->createInputValidator(...func_get_args()); - return $services->mutation("mutation_mock", $args, $validator); - }, - 'args' => [ - [ - 'name' => 'oldPassword', - 'type' => Type::string(), - 'validation' => fn() => [ - new \Symfony\Component\Security\Core\Validator\Constraints\UserPassword(), - ], - ], - ], - ], - 'expressionVariablesValidation' => [ - 'type' => Type::boolean(), - 'resolve' => function ($value, $args, $context, $info) use ($services) { - $validator = $services->createInputValidator(...func_get_args()); - return $services->mutation("mutation_mock", $args, $validator); - }, - 'args' => [ - [ - 'name' => 'username', - 'type' => Type::string(), - 'validation' => fn() => [ - new SymfonyConstraints\Expression('service_validator.resolveVariablesAccessible(args, info)'), - ], - ], - ], - ], - 'autoValidationAutoThrow' => [ - 'type' => Type::boolean(), - 'resolve' => function ($value, $args, $context, $info) use ($services) { - $validator = $services->createInputValidator(...func_get_args()); - - $validator->validate(null); - - return $services->mutation("mutation_mock", $args); - }, - 'args' => [ - [ - 'name' => 'username', - 'type' => Type::nonNull(Type::string()), - 'validation' => fn() => [ - new SymfonyConstraints\Length(null, 5), - new SymfonyConstraints\Regex('/^[a-z]+$/i'), - ], - ], - ], - ], - 'autoValidationNoThrow' => [ - 'type' => Type::boolean(), - 'resolve' => function ($value, $args, $context, $info) use ($services) { - $errors = new ResolveErrors(); - $validator = $services->createInputValidator(...func_get_args()); - - $errors->setValidationErrors($validator->validate(null, false)); - - return $services->mutation("mutation_errors", $errors); - }, - 'args' => [ - [ - 'name' => 'username', - 'type' => Type::nonNull(Type::string()), - 'validation' => fn() => [ - new SymfonyConstraints\Length(null, 5), - new SymfonyConstraints\Regex('/^[a-z]+$/i'), - ], - ], - ], - ], - 'autoValidationAutoThrowWithGroups' => [ - 'type' => Type::boolean(), - 'resolve' => function ($value, $args, $context, $info) use ($services) { - $validator = $services->createInputValidator(...func_get_args()); - - $validator->validate(['Default', 'Address', 'Period', 'group1', 'group2']); - - return $services->mutation("mutation_mock", $args); - }, - 'args' => [ - [ - 'name' => 'address', - 'type' => fn() => Type::nonNull($services->getType('Address')), - 'validation' => InputValidator::CASCADE, - ], - [ - 'name' => 'birthdate', - 'type' => fn() => $services->getType('Birthdate'), - 'validation' => [ - 'cascade' => ['group2'], - ], - ], - ], - ], - 'partialInputObjectsCollectionValidation' => [ - 'type' => Type::boolean(), - 'resolve' => function ($value, $args, $context, $info) use ($services) { - $validator = $services->createInputValidator(...func_get_args()); - - $validator->validate(null); - - return $services->mutation("mutation_mock", $args); - }, - 'args' => [ - [ - 'name' => 'addresses', - 'type' => fn() => Type::listOf($services->getType('Address')), - 'validation' => InputValidator::CASCADE, - ], - ], - ], - ], - ]; - - parent::__construct($configProcessor->process($config)); - } - - /** - * {@inheritdoc} - */ - public static function getAliases(): array - { - return [self::NAME]; - } -} \ No newline at end of file From a5078b87f0af61193958c77857225ad790ed7149 Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Wed, 28 Jan 2026 14:47:32 +0200 Subject: [PATCH 20/33] proper order is now, hmmm ... --- src/Generator/TypeBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Generator/TypeBuilder.php b/src/Generator/TypeBuilder.php index fc061a144..37d2e7eed 100644 --- a/src/Generator/TypeBuilder.php +++ b/src/Generator/TypeBuilder.php @@ -651,7 +651,7 @@ private function buildConstraints(array $constraints = [], bool $inClosure = tru $checkedPosition = 0; foreach ($args as $key => $value) { if ( - isset($parameterNames[$checkedPosition]) === true + true === isset($parameterNames[$checkedPosition]) && $parameterNames[$checkedPosition++] === $key ) { $instance->addArgument($value); From 8610f320d04569067e996e2515a118ace84a9fbc Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Wed, 28 Jan 2026 15:40:24 +0200 Subject: [PATCH 21/33] reduce tests scope remove choice validation from tests as it was not consistent with other validators in version symfony/validator:7.3.* --- src/Generator/TypeBuilder.php | 4 ++++ .../config/validator/mapping/Address.types.yml | 16 ---------------- .../config/validator/mapping/Country.types.yml | 6 ------ tests/Functional/Validator/ExpectedErrors.php | 12 ------------ .../Functional/Validator/InputValidatorTest.php | 11 ----------- 5 files changed, 4 insertions(+), 45 deletions(-) diff --git a/src/Generator/TypeBuilder.php b/src/Generator/TypeBuilder.php index 37d2e7eed..a1243d473 100644 --- a/src/Generator/TypeBuilder.php +++ b/src/Generator/TypeBuilder.php @@ -33,6 +33,7 @@ use Overblog\GraphQLBundle\Generator\Exception\GeneratorException; use Overblog\GraphQLBundle\Validator\InputValidator; use ReflectionClass; +use Composer\InstalledVersions; use function array_map; use function class_exists; @@ -639,7 +640,10 @@ private function buildConstraints(array $constraints = [], bool $inClosure = tru $reflectionClass = new ReflectionClass($fqcn); $constructor = $reflectionClass->getConstructor(); + $validatorVersion = InstalledVersions::getVersion('symfony/validator'); + $inlineParameters = false; + // if (null !== $constructor && version_compare($validatorVersion, '7.4', '>=')) { if (null !== $constructor) { $parameterNames = []; $parameters = $constructor->getParameters(); diff --git a/tests/Functional/App/config/validator/mapping/Address.types.yml b/tests/Functional/App/config/validator/mapping/Address.types.yml index e4baa02f2..8c434f7fa 100644 --- a/tests/Functional/App/config/validator/mapping/Address.types.yml +++ b/tests/Functional/App/config/validator/mapping/Address.types.yml @@ -8,22 +8,6 @@ Address: type: String! validation: - Length: { exactly: null, min: 10} - city: - type: String! - validation: - - Choice: - options: null - choices: ['New York', 'Berlin', 'Tokyo'] - callback: null - multiple: null - strict: null - min: null - max: null - message: null - multipleMessage: null - minMessage: null - maxMessage: null - groups: ['group1'] country: type: Country validation: cascade diff --git a/tests/Functional/App/config/validator/mapping/Country.types.yml b/tests/Functional/App/config/validator/mapping/Country.types.yml index e26a0615a..90b91db2d 100644 --- a/tests/Functional/App/config/validator/mapping/Country.types.yml +++ b/tests/Functional/App/config/validator/mapping/Country.types.yml @@ -9,9 +9,3 @@ Country: options: null message: null allowNull: true - officialLanguage: - type: String - validation: - - Choice: - options: null - choices: ['en', 'de', 'fr'] diff --git a/tests/Functional/Validator/ExpectedErrors.php b/tests/Functional/Validator/ExpectedErrors.php index 5aef2fb9c..5ccc95ac8 100644 --- a/tests/Functional/Validator/ExpectedErrors.php +++ b/tests/Functional/Validator/ExpectedErrors.php @@ -85,12 +85,6 @@ final class ExpectedErrors '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.', @@ -152,12 +146,6 @@ public static function cascadeWithGroups(string $fieldName): array 'code' => '778b7ae0-84d3-481a-9dec-35fdb64b1d78', ], ], - 'address.city' => [ - [ - 'message' => 'The value you selected is not a valid choice.', - 'code' => '8e179f1b-97aa-4560-a02f-2a8b42e49df7', - ], - ], 'birthdate.day' => [ [ 'message' => 'This value should be between 1 and 31.', diff --git a/tests/Functional/Validator/InputValidatorTest.php b/tests/Functional/Validator/InputValidatorTest.php index c3fe1e7e2..53fac8e6c 100644 --- a/tests/Functional/Validator/InputValidatorTest.php +++ b/tests/Functional/Validator/InputValidatorTest.php @@ -109,7 +109,6 @@ public function testCollectionValidationPasses(): void mutation { collectionValidation( addresses: [{ - city: "Berlin", street: "Brettnacher-Str. 14a", zipCode: 10546, period: { @@ -135,7 +134,6 @@ public function testCollectionValidationFails(): void mutation { collectionValidation( addresses: [{ - city: "Moscow", street: "ul. Lazo", zipCode: -15, period: { @@ -168,7 +166,6 @@ public function testCascadeValidationWithGroupsPasses(): void } address: { street: "Washington Street" - city: "New York" zipCode: 10006 period: { startDate: "2016-01-01" @@ -199,7 +196,6 @@ public function testCascadeValidationWithGroupsFails(): void } address: { street: "ul. Lazo" - city: "Moscow" zipCode: -215 period: { startDate: "2020-01-01" @@ -312,7 +308,6 @@ public function testAutoValidationAutoThrowWithGroupsPasses(): void } address: { street: "Washington Street" - city: "New York" zipCode: 10006 period: { startDate: "2016-01-01" @@ -342,7 +337,6 @@ public function testAutoValidationAutoThrowWithGroupsFails(): void } address: { street: "ul. Lazo" - city: "Moscow" zipCode: -215 period: { startDate: "2020-01-01" @@ -367,23 +361,19 @@ public function testPartialInputObjectsCollectionValidation(): void 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" @@ -392,7 +382,6 @@ public function testPartialInputObjectsCollectionValidation(): void }, { street: "Washington Street" - city: "New York" zipCode: 10000 country: {} # Empty input object, skip validation period: {} # Empty input object, skip validation From a53de4b1f2060bba76ffbf1306694492bac1be6a Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Wed, 28 Jan 2026 15:43:16 +0200 Subject: [PATCH 22/33] cq --- src/Generator/TypeBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Generator/TypeBuilder.php b/src/Generator/TypeBuilder.php index a1243d473..4f2c083ce 100644 --- a/src/Generator/TypeBuilder.php +++ b/src/Generator/TypeBuilder.php @@ -4,6 +4,7 @@ namespace Overblog\GraphQLBundle\Generator; +use Composer\InstalledVersions; use GraphQL\Language\AST\NodeKind; use GraphQL\Language\Parser; use GraphQL\Type\Definition\InputObjectType; @@ -33,7 +34,6 @@ use Overblog\GraphQLBundle\Generator\Exception\GeneratorException; use Overblog\GraphQLBundle\Validator\InputValidator; use ReflectionClass; -use Composer\InstalledVersions; use function array_map; use function class_exists; From 53b01a93c71a041261fe5143a78813a7ce7af5da Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Wed, 28 Jan 2026 15:57:13 +0200 Subject: [PATCH 23/33] exclude phps prior to 8.4 from testing with sf8 --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f4c3f970..fe1647d4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,12 @@ jobs: exclude: - php-version: '8.1' symfony-version: '7.4.*' + - php-version: '8.1' + symfony-version: '8.0.*' + - php-version: '8.2' + symfony-version: '8.0.*' + - php-version: '8.3' + symfony-version: '8.0.*' include: - php-version: '8.2' symfony-version: '5.4.*' From 760f103143ee6c5f93ff665229daf1603d046105 Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Wed, 28 Jan 2026 16:03:25 +0200 Subject: [PATCH 24/33] apply sorting on responses before comparing no need to expect same --- tests/Functional/Validator/InputValidatorTest.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/Functional/Validator/InputValidatorTest.php b/tests/Functional/Validator/InputValidatorTest.php index 53fac8e6c..d058367aa 100644 --- a/tests/Functional/Validator/InputValidatorTest.php +++ b/tests/Functional/Validator/InputValidatorTest.php @@ -62,7 +62,7 @@ public function testSimpleValidationFails(): void $result = $this->executeGraphQLRequest($query); - $this->assertSame(ExpectedErrors::simpleValidation('simpleValidation'), $result['errors'][0]); + $this->assertEqualsCanonicalizing(ExpectedErrors::simpleValidation('simpleValidation'), $result['errors'][0]); $this->assertNull($result['data']['simpleValidation']); } @@ -98,7 +98,7 @@ public function testLinkedConstraintsValidationFails(): void $result = $this->executeGraphQLRequest($query); - $this->assertSame(ExpectedErrors::LINKED_CONSTRAINTS, $result['errors'][0]); + $this->assertEqualsCanonicalizing(ExpectedErrors::LINKED_CONSTRAINTS, $result['errors'][0]); $this->assertNull($result['data']['linkedConstraintsValidation']); } @@ -148,7 +148,7 @@ public function testCollectionValidationFails(): void $result = $this->executeGraphQLRequest($query); - $this->assertSame(ExpectedErrors::COLLECTION, $result['errors'][0]); + $this->assertEqualsCanonicalizing(ExpectedErrors::COLLECTION, $result['errors'][0]); $this->assertNull($result['data']['collectionValidation']); } @@ -208,7 +208,7 @@ public function testCascadeValidationWithGroupsFails(): void $result = $this->executeGraphQLRequest($query); - $this->assertSame(ExpectedErrors::cascadeWithGroups('cascadeValidationWithGroups'), $result['errors'][0]); + $this->assertEqualsCanonicalizing(ExpectedErrors::cascadeWithGroups('cascadeValidationWithGroups'), $result['errors'][0]); $this->assertNull($result['data']['cascadeValidationWithGroups']); } @@ -265,7 +265,7 @@ public function testAutoValidationAutoThrowFails(): void $result = $this->executeGraphQLRequest($query); - $this->assertSame(ExpectedErrors::simpleValidation('autoValidationAutoThrow'), $result['errors'][0]); + $this->assertEqualsCanonicalizing(ExpectedErrors::simpleValidation('autoValidationAutoThrow'), $result['errors'][0]); $this->assertNull($result['data']['autoValidationAutoThrow']); } @@ -349,7 +349,7 @@ public function testAutoValidationAutoThrowWithGroupsFails(): void $result = $this->executeGraphQLRequest($query); - $this->assertSame(ExpectedErrors::cascadeWithGroups('autoValidationAutoThrowWithGroups'), $result['errors'][0]); + $this->assertEqualsCanonicalizing(ExpectedErrors::cascadeWithGroups('autoValidationAutoThrowWithGroups'), $result['errors'][0]); $this->assertNull($result['data']['autoValidationAutoThrowWithGroups']); } @@ -392,7 +392,7 @@ public function testPartialInputObjectsCollectionValidation(): void '; $result = $this->executeGraphQLRequest($query); - $this->assertSame(ExpectedErrors::PARTIAL_INPUT_OBJECTS_COLLECTION, $result['errors'][0]); + $this->assertEqualsCanonicalizing(ExpectedErrors::PARTIAL_INPUT_OBJECTS_COLLECTION, $result['errors'][0]); $this->assertNull($result['data']['partialInputObjectsCollectionValidation']); } } From 6fa6b82fa40387f436fb39439cbae8f25dc8f93f Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Wed, 28 Jan 2026 16:17:37 +0200 Subject: [PATCH 25/33] do not test sf7 with 8.1 --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe1647d4c..9a18ba9f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,14 @@ jobs: remove-dependencies: [ '' ] coverage: [ 'none' ] exclude: + - php-version: '8.1' + symfony-version: '7.0.*' + - php-version: '8.1' + symfony-version: '7.1.*' + - php-version: '8.1' + symfony-version: '7.2.*' + - php-version: '8.1' + symfony-version: '7.3.*' - php-version: '8.1' symfony-version: '7.4.*' - php-version: '8.1' From 236e050e06977288f3616d9e1df5ff87049da8ee Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Wed, 28 Jan 2026 16:58:23 +0200 Subject: [PATCH 26/33] exclude 7.2 lowest deps as issue with sorting in tests --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a18ba9f4..6e13405df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,8 @@ jobs: symfony-version: '8.0.*' - php-version: '8.3' symfony-version: '8.0.*' + - symfony-version: '7.2.*' + dependencies: 'lowest' include: - php-version: '8.2' symfony-version: '5.4.*' From 27d20fcf71af2c9daf6f18c3af625db4601a3d6c Mon Sep 17 00:00:00 2001 From: Alexander Chertovsky Date: Wed, 28 Jan 2026 17:04:08 +0200 Subject: [PATCH 27/33] sf8 one more exclude --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e13405df..97a7fa850 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,9 @@ jobs: symfony-version: '8.0.*' - symfony-version: '7.2.*' dependencies: 'lowest' + - php-version: '8.4' + symfony-version: '7.0.*' + dependencies: 'lowest' include: - php-version: '8.2' symfony-version: '5.4.*' From f5f6327eef6451fed988b9576f60ddd823ee147f Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Wed, 28 Jan 2026 18:37:02 +0100 Subject: [PATCH 28/33] fix CI config --- .github/workflows/ci.yml | 26 ++++---------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97a7fa850..3baf6a5e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,40 +23,22 @@ jobs: symfony-version: - '5.4.*' - '6.4.*' - - '7.0.*' - - '7.1.*' - - '7.2.*' - - '7.3.*' - '7.4.*' - - '8.0.*' dependencies: - 'lowest' - 'highest' remove-dependencies: [ '' ] coverage: [ 'none' ] exclude: - - php-version: '8.1' - symfony-version: '7.0.*' - - php-version: '8.1' - symfony-version: '7.1.*' - - php-version: '8.1' - symfony-version: '7.2.*' - - php-version: '8.1' - symfony-version: '7.3.*' - php-version: '8.1' symfony-version: '7.4.*' - - php-version: '8.1' - symfony-version: '8.0.*' - - php-version: '8.2' - symfony-version: '8.0.*' - - php-version: '8.3' + include: + - php-version: '8.4' symfony-version: '8.0.*' - - symfony-version: '7.2.*' dependencies: 'lowest' - php-version: '8.4' - symfony-version: '7.0.*' - dependencies: 'lowest' - include: + symfony-version: '8.0.*' + dependencies: 'highest' - php-version: '8.2' symfony-version: '5.4.*' dependencies: 'lowest' From fd68937f1b9efcd6b0a59d5d6ebde0f41a50bbf3 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Wed, 28 Jan 2026 18:58:18 +0100 Subject: [PATCH 29/33] handle parameters by name --- src/Generator/TypeBuilder.php | 36 ++++++++++--------- .../validator/mapping/Address.types.yml | 8 ++++- .../validator/mapping/Birthdate.types.yml | 35 ++---------------- .../validator/mapping/Country.types.yml | 6 ++-- .../validator/mapping/Mutation.types.yml | 19 +++++----- .../config/validator/mapping/Period.types.yml | 1 - tests/Functional/Validator/ExpectedErrors.php | 12 +++++++ .../Validator/InputValidatorTest.php | 25 +++++++++---- 8 files changed, 74 insertions(+), 68 deletions(-) diff --git a/src/Generator/TypeBuilder.php b/src/Generator/TypeBuilder.php index 4f2c083ce..0a4afd7a1 100644 --- a/src/Generator/TypeBuilder.php +++ b/src/Generator/TypeBuilder.php @@ -34,6 +34,8 @@ use Overblog\GraphQLBundle\Generator\Exception\GeneratorException; use Overblog\GraphQLBundle\Validator\InputValidator; use ReflectionClass; +use Symfony\Component\Validator\Constraints\Choice; +use Symfony\Component\Validator\Constraints\Video; use function array_map; use function class_exists; @@ -83,6 +85,7 @@ final class TypeBuilder private string $type; private string $currentField; private string $gqlServices = '$'.TypeGenerator::GRAPHQL_SERVICES; + private bool $isSymfony74Plus; public function __construct(ExpressionConverter $expressionConverter, string $namespace) { @@ -91,6 +94,8 @@ public function __construct(ExpressionConverter $expressionConverter, string $na // Register additional converter in the php code generator Config::registerConverter($expressionConverter, ConverterInterface::TYPE_STRING); + $this->isSymfony74Plus = class_exists(Video::class); + } /** @@ -640,26 +645,25 @@ private function buildConstraints(array $constraints = [], bool $inClosure = tru $reflectionClass = new ReflectionClass($fqcn); $constructor = $reflectionClass->getConstructor(); - $validatorVersion = InstalledVersions::getVersion('symfony/validator'); + if ($this->isSymfony74Plus && isset($args[0]) !== false && $fqcn === Choice::class) { + // Handle Choice constraint in Symfony 7.4+ + $args = ['choices'=>$args]; + } - $inlineParameters = false; - // if (null !== $constructor && version_compare($validatorVersion, '7.4', '>=')) { - if (null !== $constructor) { - $parameterNames = []; + /* + * In Symfony 7.4+, we should not pass an array, but split up parameters in different arguments. + */ + $inlineParameters = $this->isSymfony74Plus && isset($args[0]) === false; + if (null !== $constructor && $inlineParameters === true) { $parameters = $constructor->getParameters(); foreach ($parameters as $parameter) { $name = $parameter->getName(); - $parameterNames[] = $name; - } - - $checkedPosition = 0; - foreach ($args as $key => $value) { - if ( - true === isset($parameterNames[$checkedPosition]) - && $parameterNames[$checkedPosition++] === $key - ) { - $instance->addArgument($value); - $inlineParameters = true; + if (isset($args[$name])) { + $instance->addArgument($args[$name]); + } elseif ($parameter->isDefaultValueAvailable()) { + $instance->addArgument($parameter->getDefaultValue()); + } else { + throw new GeneratorException("Constraint '$fqcn' requires argument '$name'."); } } } diff --git a/tests/Functional/App/config/validator/mapping/Address.types.yml b/tests/Functional/App/config/validator/mapping/Address.types.yml index 8c434f7fa..1e3a6c343 100644 --- a/tests/Functional/App/config/validator/mapping/Address.types.yml +++ b/tests/Functional/App/config/validator/mapping/Address.types.yml @@ -7,7 +7,13 @@ Address: street: type: String! validation: - - Length: { exactly: null, min: 10} + - Length: {min: 10} + city: + type: String! + validation: + - Choice: + groups: ['group1'] + choices: ['New York', 'Berlin', 'Tokyo'] country: type: Country validation: cascade diff --git a/tests/Functional/App/config/validator/mapping/Birthdate.types.yml b/tests/Functional/App/config/validator/mapping/Birthdate.types.yml index 168b9f1e7..d7f928831 100644 --- a/tests/Functional/App/config/validator/mapping/Birthdate.types.yml +++ b/tests/Functional/App/config/validator/mapping/Birthdate.types.yml @@ -5,41 +5,12 @@ Birthdate: day: type: Int! validation: - - Range: - options: null - notInRangeMessage: null - minMessage: null - maxMessage: null - invalidMessage: null - invalidDateTimeMessage: null - min: 1 - minPropertyPath: null - max: 31 - maxPropertyPath: null - groups: ["group2"] + - Range: { min: 1, max: 31, groups: ["group2"] } month: type: Int! validation: - - Range: - options: null - notInRangeMessage: null - minMessage: null - maxMessage: null - invalidMessage: null - invalidDateTimeMessage: null - min: 1 - minPropertyPath: null - max: 12 + - Range: { min: 1, max: 12 } year: type: Int! validation: - - Range: - options: null - notInRangeMessage: null - minMessage: null - maxMessage: null - invalidMessage: null - invalidDateTimeMessage: null - min: 1901 - minPropertyPath: null - max: 2019 + - Range: { min: 1901, max: 2019 } diff --git a/tests/Functional/App/config/validator/mapping/Country.types.yml b/tests/Functional/App/config/validator/mapping/Country.types.yml index 90b91db2d..09f2f266e 100644 --- a/tests/Functional/App/config/validator/mapping/Country.types.yml +++ b/tests/Functional/App/config/validator/mapping/Country.types.yml @@ -6,6 +6,8 @@ Country: type: String validation: - NotBlank: - options: null - message: null 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 0a3b9b924..6c85fd67c 100644 --- a/tests/Functional/App/config/validator/mapping/Mutation.types.yml +++ b/tests/Functional/App/config/validator/mapping/Mutation.types.yml @@ -3,7 +3,9 @@ Mutation: config: validation: # Applied to all fields - Callback: [Overblog\GraphQLBundle\Tests\Functional\Validator\StaticValidator, alwaysTrue] - - Expression: this.getFieldName() == this.getFieldName() + - Expression: + expression: this.getFieldName() == this.getFieldName() + message: "parent" fields: noValidation: complexity: 1 @@ -18,14 +20,16 @@ Mutation: simpleValidation: validation: # Applied to all fields - - Expression: this.getFieldName() == this.getFieldName() + - Expression: + expression: this.getFieldName() == this.getFieldName() + message: "child" type: Boolean resolve: "@=m('mutation_mock', args, validator)" args: username: type: String! validation: - - Length: { exactly: null, min: 5 } + - Length: { min: 5 } - Regex: pattern: '/^[a-z]+$/i' htmlPattern: true @@ -59,12 +63,9 @@ Mutation: type: '[String]!' validation: - Unique: ~ - - Count: - exactly: null - min: 3 + - Count: { min: 3 } - All: - Email: - options: null message: 'The email "{{ value }}" is not a valid email.' cascadeValidationWithGroups: @@ -107,7 +108,7 @@ Mutation: username: type: String! validation: - - Length: { exactly: null, min: 5 } + - Length: { min: 5 } - Regex: pattern: '/^[a-z]+$/i' htmlPattern: true @@ -119,7 +120,7 @@ Mutation: username: type: String! validation: - - Length: { exactly: null, min: 5 } + - Length: { min: 5 } - Regex: pattern: '/^[a-z]+$/i' htmlPattern: true diff --git a/tests/Functional/App/config/validator/mapping/Period.types.yml b/tests/Functional/App/config/validator/mapping/Period.types.yml index 1b9c295b7..557506553 100644 --- a/tests/Functional/App/config/validator/mapping/Period.types.yml +++ b/tests/Functional/App/config/validator/mapping/Period.types.yml @@ -17,5 +17,4 @@ Period: - Expression: "this.getParent().getName() === 'Address'" - Date: ~ - GreaterThan: - value: null propertyPath: 'startDate' diff --git a/tests/Functional/Validator/ExpectedErrors.php b/tests/Functional/Validator/ExpectedErrors.php index 5ccc95ac8..5aef2fb9c 100644 --- a/tests/Functional/Validator/ExpectedErrors.php +++ b/tests/Functional/Validator/ExpectedErrors.php @@ -85,6 +85,12 @@ final class ExpectedErrors '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.', @@ -146,6 +152,12 @@ public static function cascadeWithGroups(string $fieldName): array 'code' => '778b7ae0-84d3-481a-9dec-35fdb64b1d78', ], ], + 'address.city' => [ + [ + 'message' => 'The value you selected is not a valid choice.', + 'code' => '8e179f1b-97aa-4560-a02f-2a8b42e49df7', + ], + ], 'birthdate.day' => [ [ 'message' => 'This value should be between 1 and 31.', diff --git a/tests/Functional/Validator/InputValidatorTest.php b/tests/Functional/Validator/InputValidatorTest.php index d058367aa..c3fe1e7e2 100644 --- a/tests/Functional/Validator/InputValidatorTest.php +++ b/tests/Functional/Validator/InputValidatorTest.php @@ -62,7 +62,7 @@ public function testSimpleValidationFails(): void $result = $this->executeGraphQLRequest($query); - $this->assertEqualsCanonicalizing(ExpectedErrors::simpleValidation('simpleValidation'), $result['errors'][0]); + $this->assertSame(ExpectedErrors::simpleValidation('simpleValidation'), $result['errors'][0]); $this->assertNull($result['data']['simpleValidation']); } @@ -98,7 +98,7 @@ public function testLinkedConstraintsValidationFails(): void $result = $this->executeGraphQLRequest($query); - $this->assertEqualsCanonicalizing(ExpectedErrors::LINKED_CONSTRAINTS, $result['errors'][0]); + $this->assertSame(ExpectedErrors::LINKED_CONSTRAINTS, $result['errors'][0]); $this->assertNull($result['data']['linkedConstraintsValidation']); } @@ -109,6 +109,7 @@ public function testCollectionValidationPasses(): void mutation { collectionValidation( addresses: [{ + city: "Berlin", street: "Brettnacher-Str. 14a", zipCode: 10546, period: { @@ -134,6 +135,7 @@ public function testCollectionValidationFails(): void mutation { collectionValidation( addresses: [{ + city: "Moscow", street: "ul. Lazo", zipCode: -15, period: { @@ -148,7 +150,7 @@ public function testCollectionValidationFails(): void $result = $this->executeGraphQLRequest($query); - $this->assertEqualsCanonicalizing(ExpectedErrors::COLLECTION, $result['errors'][0]); + $this->assertSame(ExpectedErrors::COLLECTION, $result['errors'][0]); $this->assertNull($result['data']['collectionValidation']); } @@ -166,6 +168,7 @@ public function testCascadeValidationWithGroupsPasses(): void } address: { street: "Washington Street" + city: "New York" zipCode: 10006 period: { startDate: "2016-01-01" @@ -196,6 +199,7 @@ public function testCascadeValidationWithGroupsFails(): void } address: { street: "ul. Lazo" + city: "Moscow" zipCode: -215 period: { startDate: "2020-01-01" @@ -208,7 +212,7 @@ public function testCascadeValidationWithGroupsFails(): void $result = $this->executeGraphQLRequest($query); - $this->assertEqualsCanonicalizing(ExpectedErrors::cascadeWithGroups('cascadeValidationWithGroups'), $result['errors'][0]); + $this->assertSame(ExpectedErrors::cascadeWithGroups('cascadeValidationWithGroups'), $result['errors'][0]); $this->assertNull($result['data']['cascadeValidationWithGroups']); } @@ -265,7 +269,7 @@ public function testAutoValidationAutoThrowFails(): void $result = $this->executeGraphQLRequest($query); - $this->assertEqualsCanonicalizing(ExpectedErrors::simpleValidation('autoValidationAutoThrow'), $result['errors'][0]); + $this->assertSame(ExpectedErrors::simpleValidation('autoValidationAutoThrow'), $result['errors'][0]); $this->assertNull($result['data']['autoValidationAutoThrow']); } @@ -308,6 +312,7 @@ public function testAutoValidationAutoThrowWithGroupsPasses(): void } address: { street: "Washington Street" + city: "New York" zipCode: 10006 period: { startDate: "2016-01-01" @@ -337,6 +342,7 @@ public function testAutoValidationAutoThrowWithGroupsFails(): void } address: { street: "ul. Lazo" + city: "Moscow" zipCode: -215 period: { startDate: "2020-01-01" @@ -349,7 +355,7 @@ public function testAutoValidationAutoThrowWithGroupsFails(): void $result = $this->executeGraphQLRequest($query); - $this->assertEqualsCanonicalizing(ExpectedErrors::cascadeWithGroups('autoValidationAutoThrowWithGroups'), $result['errors'][0]); + $this->assertSame(ExpectedErrors::cascadeWithGroups('autoValidationAutoThrowWithGroups'), $result['errors'][0]); $this->assertNull($result['data']['autoValidationAutoThrowWithGroups']); } @@ -361,19 +367,23 @@ public function testPartialInputObjectsCollectionValidation(): void 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" @@ -382,6 +392,7 @@ public function testPartialInputObjectsCollectionValidation(): void }, { street: "Washington Street" + city: "New York" zipCode: 10000 country: {} # Empty input object, skip validation period: {} # Empty input object, skip validation @@ -392,7 +403,7 @@ public function testPartialInputObjectsCollectionValidation(): void '; $result = $this->executeGraphQLRequest($query); - $this->assertEqualsCanonicalizing(ExpectedErrors::PARTIAL_INPUT_OBJECTS_COLLECTION, $result['errors'][0]); + $this->assertSame(ExpectedErrors::PARTIAL_INPUT_OBJECTS_COLLECTION, $result['errors'][0]); $this->assertNull($result['data']['partialInputObjectsCollectionValidation']); } } From c80aefe3df07537d216fe2a4358644dcd8c4e9e2 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Wed, 28 Jan 2026 19:00:01 +0100 Subject: [PATCH 30/33] revert index.md --- docs/validation/index.md | 345 +++++++++++++++++++-------------------- 1 file changed, 168 insertions(+), 177 deletions(-) diff --git a/docs/validation/index.md b/docs/validation/index.md index 7390ec711..0c640de7d 100644 --- a/docs/validation/index.md +++ b/docs/validation/index.md @@ -2,49 +2,41 @@ # Validation -This bundle provides a tight integration with the [Symfony Validator Component](https://symfony.com/doc/current/components/validator.html) +This bundle provides a tight integration with the [Symfony Validator Component](https://symfony.com/doc/current/components/validator.html) to validate user input data. It currently supports only GraphQL schemas defined with YAML. ### Contents: -- [Validation](#validation) - - [Contents:](#contents) - - [Overview](#overview) - - [How does it work?](#how-does-it-work) - - [Applying validation constraints](#applying-validation-constraints) +- [Overview](#overview) +- [How does it work?](#how-does-it-work) +- [Applying validation constraints](#applying-validation-constraints) - [Listing constraints directly](#listing-constraints-directly) - - [object:](#object) - - [input-object:](#input-object) + - [object](#object) + - [input-object](#input-object) - [Linking to class constraints](#linking-to-class-constraints) - - [Example:](#example) - - [Context of linked constraints](#context-of-linked-constraints) - - [Validation groups of linked constraints](#validation-groups-of-linked-constraints) + - [Context of linked constraints](#context-of-linked-constraints) + - [Validation groups of linked constraints](#validation-groups-of-linked-constraints) - [Cascade](#cascade) - - [Example:](#example-1) - - [Groups](#groups) - - [Group Sequences](#group-sequences) - - [Validating inside resolvers](#validating-inside-resolvers) - - [Injecting `errors`](#injecting-errors) - - [Error Messages](#error-messages) - - [Customizing the response](#customizing-the-response) - - [Translations](#translations) - - [Using built-in expression functions](#using-built-in-expression-functions) - - [ValidationNode API](#validationnode-api) - - [Methods](#methods) - - [Examples](#examples) - - [Usage in the `Expression` constraints:](#usage-in-the-expression-constraints) - - [Usage with `Callback` constraints:](#usage-with-callback-constraints) - - [Limitations](#limitations) +- [Groups](#groups) +- [Group Sequences](#group-sequences) +- [Validating inside resolvers](#validating-inside-resolvers) +- [Injecting errors](#injecting-errors) +- [Error messages](#error-messages) + - [Customizing the response](#customizing-the-response) +- [Translations](#translations) +- [Using built-in expression functions](#using-built-in-expression-functions) +- [ValidationNode API](#validationnode-api) +- [Limitations](#limitations) - [Annotations and GraphQL Schema language](#annotations-and-graphql-schema-language) - [Unsupported/Irrelevant constraints](#unsupportedirrelevant-constraints) ## Overview -In order to validate input data, the only thing you need to do is to apply [constraints](https://symfony.com/doc/current/reference/constraints.html) -in your `yaml` type definitions (`args` by `object` types and `fields` by `input-object` types). The bundle will then -automatically validate the data and throw an exception, which will be caught and returned in the response back to the +In order to validate input data, the only thing you need to do is to apply [constraints](https://symfony.com/doc/current/reference/constraints.html) +in your `yaml` type definitions (`args` by `object` types and `fields` by `input-object` types). The bundle will then +automatically validate the data and throw an exception, which will be caught and returned in the response back to the client. -Follow the example below to get a quick overview of the most basic validation capabilities of this bundle. +Follow the example below to get a quick overview of the most basic validation capabilities of this bundle. ```yaml # config\graphql\types\Mutation.yaml Mutation: @@ -65,12 +57,11 @@ Mutation: type: String! validation: # applying constraints to `password` - Length: - exactly: null # for Symfony 8+ due to validation constructor changes it is required to define in configs constructor parameters in corresponding order, even with default values min: 8 max: 32 - IdenticalTo: propertyPath: passwordRepeat - passwordRepeat: + passwordRepeat: type: String! emails: type: "[String]" @@ -84,7 +75,7 @@ Mutation: birthdate: type: Birthdate validation: cascade # delegating validation to the embedded type - + Birthdate: type: input-object config: @@ -98,14 +89,14 @@ Birthdate: validation: - Range: { min: 1, max: 12 } year: - type: Int! + type: Int! validation: - Range: { min: 1900, max: 2019 } ``` The configuration above checks, that: -- **username** +- **username** - has length between 6 and 32 -- **password** +- **password** - has length between 8 and 32 - is equal to the *passwordRepeat* value - **emails** @@ -121,42 +112,42 @@ The `birthdate` field is of type `input-object` and is marked as `cascade`, so i ## How does it work? -The [Symfony Validator Component](https://symfony.com/doc/current/components/validator.html) is designed to validate -objects. For this reason, when this bundle starts a validation, all input data is first converted into objects of class -[`ValidationNode`](#validationnode-api) and then validated. This process is performed -automatically by the bundle just **before** calling corresponding resolvers (each resolver gets its own `InputValidator` +The [Symfony Validator Component](https://symfony.com/doc/current/components/validator.html) is designed to validate +objects. For this reason, when this bundle starts a validation, all input data is first converted into objects of class +[`ValidationNode`](#validationnode-api) and then validated. This process is performed +automatically by the bundle just **before** calling corresponding resolvers (each resolver gets its own `InputValidator` instance). If validation fails, the corresponding resolver will not be called (except when you perform [validation inside your resolvers](#validating-inside-resolvers)). - -> Note that the created objects are only used for validation purposes. Your resolvers will receive raw unaltered + +> Note that the created objects are only used for validation purposes. Your resolvers will receive raw unaltered > arguments as usual. Validation objects are created differently depending on the GraphQL type. Take a look at the following scheme: ![enter_description](img/schema_1.png) -As you can see, there are 2 GraphQL types: **Mutation** and **DateInput** (`object` and `input-object` respectively). In -the case of **Mutation**, this bundle creates an object **per each field** (`createUser` and `createPost`), but in the -case of the **DateInput**, it creates an object for the entire type. +As you can see, there are 2 GraphQL types: **Mutation** and **DateInput** (`object` and `input-object` respectively). In +the case of **Mutation**, this bundle creates an object **per each field** (`createUser` and `createPost`), but in the +case of the **DateInput**, it creates an object for the entire type. Keep in mind that objects are not created recursively by default. As you can see, the argument `createdAt` has its -validation set to `cascade`. It is a special value, which delegates the validation to the embedded type by doing the +validation set to `cascade`. It is a special value, which delegates the validation to the embedded type by doing the following: - convert the subtype (`DateInput`) into an object. - embed the resulting object into its parent, making it a sub-object. - apply to it the [`Valid`](https://symfony.com/doc/current/reference/constraints/Valid.html) constraint (for a - recursive validation). - -If you don't mark embedded types as `cascade`, they will stay arrays, which can still be validated, as shown in the + recursive validation). + +If you don't mark embedded types as `cascade`, they will stay arrays, which can still be validated, as shown in the following examples. -All object properties are created dynamically and then the validation constraints are applied to them. The resulting +All object properties are created dynamically and then the validation constraints are applied to them. The resulting object composition will then be recursively validated, starting from the root object down to its children. -> **Note**: +> **Note**: > Although it would have been possible to validate raw arguments, objects provide a better flexibility and more features. -Here is a more complex example to better demonstrate how the `InputValidator` creates objects from your GraphQL schema +Here is a more complex example to better demonstrate how the `InputValidator` creates objects from your GraphQL schema and embeds them into each other: ```yaml Mutation: @@ -208,7 +199,7 @@ Mutation: - Length: { min: 2, max: 64 } zip: - Positive: ~ - + Job: type: input-object config: @@ -241,8 +232,8 @@ Address: zip: type: Int! validation: - - Positive: ~ - + - Positive: ~ + Period: type: input-object config: @@ -256,7 +247,7 @@ Period: validation: - Date: ~ - GreaterThan: - propertyPath: 'startDate' + propertyPath: 'startDate' Birthday: type: input-object @@ -271,9 +262,9 @@ Birthday: validation: - Range: { min: 1, max: 12 } year: - type: Int! + type: Int! validation: - - Range: { min: 1900, max: today } + - Range: { min: 1900, max: today } ``` The configuration above would produce an object composition as shown in the UML diagram below: @@ -285,9 +276,9 @@ The configuration above would produce an object composition as shown in the UML ## Applying validation constraints -If you are familiar with the Symfony Validator Component, you might know that constraints can have different -[targets](https://symfony.com/doc/current/validation.html#constraint-targets) (class members or entire classes). Since -all input data is represented by objects during the validation, you can also declare member constraints as well as class +If you are familiar with the Symfony Validator Component, you might know that constraints can have different +[targets](https://symfony.com/doc/current/validation.html#constraint-targets) (class members or entire classes). Since +all input data is represented by objects during the validation, you can also declare member constraints as well as class constraints. There are 3 different methods to apply validation constraints: @@ -299,7 +290,7 @@ All 3 methods can be mixed, but if you use only 1 method you can omit the corres under `validation`. ### Listing constraints directly -The most straightforward way to apply validation constraints is to list them under the `constraints` key. In the chapter +The most straightforward way to apply validation constraints is to list them under the `constraints` key. In the chapter [Overview](#overview) this method has already been demonstrated. Follow the examples below to see how to use _only_ this method, as well as in combinations with [linking](#linking-to-class-constraints): @@ -317,13 +308,13 @@ Mutation: username: type: String validation: # using an explicit list of constraints (short form) - - NotBlank: ~ + - NotBlank: ~ - Length: min: 6 max: 32 minMessage: "Username must have {{ limit }} characters or more" maxMessage: "Username must have {{ limit }} characters or less" - + email: type: String validation: App\Entity\User::$email # using a link (short form) @@ -347,9 +338,9 @@ Mutation: type: User resolve: "@=mutation('updateUser', [args])" args: - username: String + username: String email: String - info: String + info: String ``` It's also possible to declare validation constraints for the entire _type_. This is useful if you don't want to repeat the configuration for each field or if you want to move the entire validation logic into a function: ```yaml @@ -363,17 +354,17 @@ Mutation: type: User resolve: "@=mutation('createUser', [args])" args: - username: String + username: String email: String info: String updateUser: type: User resolve: "@=mutation('updateUser', [args])" args: - username: String + username: String email: String info: String - + ``` which is equal to: ```yaml @@ -387,7 +378,7 @@ Mutation: type: User resolve: "@=mutation('createUser', [args])" args: - username: String + username: String email: String info: String updateUser: @@ -396,16 +387,16 @@ Mutation: type: User resolve: "@=mutation('updateUser', [args])" args: - username: String + username: String email: String info: String - + ``` #### input-object: -`input-object` types are designed to be used as arguments in other types. Basically, they are composite arguments, so -the *property* constraints are declared for each _field_ unlike `object` types, where the property constraints are +`input-object` types are designed to be used as arguments in other types. Basically, they are composite arguments, so +the *property* constraints are declared for each _field_ unlike `object` types, where the property constraints are declared for each _argument_: ```yaml User: @@ -452,19 +443,19 @@ A `link` can have 4 different forms, each of which targets different parts of a - **class**: `` - the absence of a class member indicates an entire class. for example: - - **property**: `App\Entity\User::$username` - copies constraints of the property `$username` of the class `User`. + - **property**: `App\Entity\User::$username` - copies constraints of the property `$username` of the class `User`. - **getters**: `App\Entity\User::username()` - copies constraints of the getters `getUsername()`, `isUsername()` and `hasUsername()`. - **property and getters**: `App\Entity\User::username` - copies constraints of the property `$username` and its getters `getUsername()`, `isUsername()` and `hasUsername()`. - **class**: `App\Entity\User` - copies constraints applied to the entire class `User`. > **Note**: > If you target only getters, then prefixes must be omitted. For example, if you want to target getters of the class `User` with the names `isChild()` and `hasChildren()`, then the link would be `App\Entity\User::child()`. -> +> > Only getters with the prefix `get`, `has`, and `is` will be searched. > **Note**: -> Linked constraints which work in a context (e.g. Expression or Callback) will NOT copy the context of the linked ->class, but instead will work in its own context. That means that the `this` variable won't point to the linked class +> Linked constraints which work in a context (e.g. Expression or Callback) will NOT copy the context of the linked +>class, but instead will work in its own context. That means that the `this` variable won't point to the linked class >instance, but will point to an object of the class `ValidationNode` representing your input data. See the [How does it work?](#how-does-it-work) section for more details about internal work of the validation process. #### Example: @@ -477,18 +468,18 @@ use Symfony\Component\Validator\Constraints as Assert; /** * @Assert\Callback({"App\Validation\PostValidator", "validate"}) */ -class Post +class Post { /** * @Assert\NotBlank() */ private $title; - + /** * @Assert\Length(max=512) */ private $text; - + /** * @Assert\Length(min=5, max=10) */ @@ -496,7 +487,7 @@ class Post { return $this->title; } - + /** * @Assert\EqualTo("Lorem Ipsum") */ @@ -504,7 +495,7 @@ class Post { return strlen($this->title) !== 0; } - + /** * @Assert\Json() */ @@ -548,13 +539,13 @@ or use the short form (omitting the `link` key), which is equal to the config ab validation: App\Entity\Post::$text # only property # ... ``` -The argument `title` will get 3 assertions: `NotBlank()`, `Length(min=5, max=10)` and `EqualTo("Lorem Ipsum")`, whereas -the argument `text` will only get `Length(max=512)`. The method `validate` of the class `PostValidator` will also be +The argument `title` will get 3 assertions: `NotBlank()`, `Length(min=5, max=10)` and `EqualTo("Lorem Ipsum")`, whereas +the argument `text` will only get `Length(max=512)`. The method `validate` of the class `PostValidator` will also be called once, given an object representing the input data. #### Context of linked constraints -When linking constraints, keep in mind that the validation context won't be inherited (copied). For example, suppose you +When linking constraints, keep in mind that the validation context won't be inherited (copied). For example, suppose you have the following Doctrine entity: ```php @@ -563,9 +554,9 @@ namespace App\Entity; /** * @Assert\Callback("validate") */ -class User +class User { - public static function validate() + public static function validate() { // ... } @@ -582,21 +573,21 @@ Mutation: resolve: "@=res('createUser', [args])" # ... ``` -Now, when you try to validate the arguments in your resolver, it will throw an exception, because it will try to call a -method with the name `validate` on the object of class `ValidationNode`, which doesn't have such. As explained in the -section [How does it work?](#how-does-it-work) all input data is represented objects of class `ValidationNode` during +Now, when you try to validate the arguments in your resolver, it will throw an exception, because it will try to call a +method with the name `validate` on the object of class `ValidationNode`, which doesn't have such. As explained in the +section [How does it work?](#how-does-it-work) all input data is represented objects of class `ValidationNode` during the validation process. #### Validation groups of linked constraints -Linked constraints will be used _as it is_. This means that it's not possible to change any of their params including -_groups_. For example, if you link a _property_ on class `User`, then all copied constraints will be in the groups +Linked constraints will be used _as it is_. This means that it's not possible to change any of their params including +_groups_. For example, if you link a _property_ on class `User`, then all copied constraints will be in the groups `Default` and `User` (unless other groups declared explicitly in the linked class itself). ### Cascade -The validation of arguments of the type `input-object`, which are marked as `cascade`, will be delegated to the embedded +The validation of arguments of the type `input-object`, which are marked as `cascade`, will be delegated to the embedded type. The nesting can be any depth. #### Example: @@ -609,7 +600,7 @@ Mutation: type: Post resolve: "@=mutation('update_user', [args])" args: - id: + id: type: ID! address: type: AddressInput @@ -653,22 +644,22 @@ PeriodInput: ## Groups -It is possible to organize constraints into [validation groups](https://symfony.com/doc/current/validation/groups.html). -By default, if you don't declare groups explicitly, every constraint of your type will be in 2 groups: **Default** and +It is possible to organize constraints into [validation groups](https://symfony.com/doc/current/validation/groups.html). +By default, if you don't declare groups explicitly, every constraint of your type will be in 2 groups: **Default** and the name of the type. For example, if the type's name is **Mutation** and the declaration of constraint is `NotBlank: ~` - (no explicit groups declared), then it automatically falls into 2 default groups: **Default** and **Mutation**. These - default groups will be removed, if you declare groups explicitly. Follow the - [link](https://symfony.com/doc/current/validation/groups.html) for more details about validation groups in the Symfony + (no explicit groups declared), then it automatically falls into 2 default groups: **Default** and **Mutation**. These + default groups will be removed, if you declare groups explicitly. Follow the + [link](https://symfony.com/doc/current/validation/groups.html) for more details about validation groups in the Symfony Validator Component. -Validation groups could be useful if you use a same `input-object` type in different contexts and want it to be +Validation groups could be useful if you use a same `input-object` type in different contexts and want it to be validated differently (with different groups). Take a look at the following example: ```yaml Mutation: type: object config: fields: - registerUser: + registerUser: type: User resolve: "@=mut('register_user')" validationGroups: ['User'] @@ -676,7 +667,7 @@ Mutation: input: type: UserInput! validation: cascade - registerAdmin: + registerAdmin: type: User resolve: "@=mut('register_admin')" validationGroups: ['Admin'] @@ -699,15 +690,15 @@ UserInput: - Length: {min: 4, max: 32, groups: 'User'} - Length: {min: 10, max: 32, groups: 'Admin'} ``` -As you can see the `password` field of the `UserInput` type has a same constraint applied to it twice, but with -different groups. The `validationGroups` option ensures that validation will only use the constraints that are listed +As you can see the `password` field of the `UserInput` type has a same constraint applied to it twice, but with +different groups. The `validationGroups` option ensures that validation will only use the constraints that are listed in it. In case you inject the validator into the resolver (as described [here](#validating-inside-resolvers)), the `validationGroups` -option will be ignored. Instead you should pass groups directly to the injected validator. This approach could be +option will be ignored. Instead you should pass groups directly to the injected validator. This approach could be necessary in some few cases. -Let's take the example from the chapter [Overview](#overview) and edit the configuration to inject the `validator` and +Let's take the example from the chapter [Overview](#overview) and edit the configuration to inject the `validator` and to use validation groups: ```yaml # config\graphql\types\Mutation.yaml @@ -735,7 +726,7 @@ Mutation: - IdenticalTo: propertyPath: passwordRepeat groups: ['registration'] - passwordRepeat: + passwordRepeat: type: String! emails: type: "[String]" @@ -749,7 +740,7 @@ Mutation: birthday: type: Birthday validation: cascade - + Birthday: type: input-object config: @@ -763,12 +754,12 @@ Birthday: validation: - Range: { min: 1, max: 12 } year: - type: Int! + type: Int! validation: - Range: { min: 1900, max: today } ``` -Here we injected the `validator` variable into the `register` resolver. By doing so we are turning the automatic -validation off to perform it inside the resolver (see [Validating inside resolvers](#validating-inside-resolvers)). The +Here we injected the `validator` variable into the `register` resolver. By doing so we are turning the automatic +validation off to perform it inside the resolver (see [Validating inside resolvers](#validating-inside-resolvers)). The injected instance of the `InputValidator` class could be used in a resolver as follows: ```php namespace App\GraphQL\Mutation\Mutation @@ -783,14 +774,14 @@ class UserResolver implements MutationInterface, AliasedInterface public function register(Argument $args, InputValidator $validator) { - /* + /* * Validates: * - username against 'Length' * - password against 'IdenticalTo' */ $validator->validate('registration'); - - /* + + /* * Validates: * - password against 'Length' * - emails against 'Unique', 'Count' and 'All' @@ -798,17 +789,17 @@ class UserResolver implements MutationInterface, AliasedInterface * - day against 'Range' * - month against 'Range' * - year against 'Range' - */ + */ $validator->validate('Default'); // ... which is in this case equal to: - $validator->validate(); - - /** - * Validates only arguments in the 'Birthday' type + $validator->validate(); + + /** + * Validates only arguments in the 'Birthday' type * against constraints with no explicit groups. */ - $validator->validate('Birthdate'); - + $validator->validate('Birthdate'); + // Validates all arguments in each type against all constraints. $validator->validate(['registration', 'Default']); // ... which is in this case equal to: @@ -818,7 +809,7 @@ class UserResolver implements MutationInterface, AliasedInterface public static function getAliases(): array { return ['register' => 'register']; - } + } } ``` > **Note**: @@ -857,8 +848,8 @@ Mutation: ``` ## Validating inside resolvers -You can turn the auto-validation off by injecting the validator into your resolver. This can be useful if you want to -do something before the actual validation happens or customize other aspects, for example validate data multiple times +You can turn the auto-validation off by injecting the validator into your resolver. This can be useful if you want to +do something before the actual validation happens or customize other aspects, for example validate data multiple times with different groups or make the validation conditional. Here is how you can inject the validator: @@ -884,7 +875,7 @@ class UserResolver implements MutationInterface, AliasedInterface { public function register(Argument $args, InputValidator $validator): User { - // This line executes a validation process and throws ArgumentsValidationException + // This line executes a validation process and throws ArgumentsValidationException // on fail. The client will then get a well formatted error message. $validator->validate(); @@ -894,21 +885,21 @@ class UserResolver implements MutationInterface, AliasedInterface // Or use a short syntax, which is equal to $validator->validate(). // This is possible thanks to the __invoke magic method. $validator(); - + // The code below won't be reached if one of the validations above fails $user = $this->userManager->createUser($args); $this->userManager->save($user); - + return $user; } public static function getAliases(): array { return ['register' => 'register']; - } + } } ``` -If you want to prevent the validator to automatically throw an exception just pass `false` as the second argument. It +If you want to prevent the validator to automatically throw an exception just pass `false` as the second argument. It will return an instance of the `ConstraintViolationList` class instead: ```php $errors = $validator->validate('my_group', false); @@ -943,22 +934,22 @@ class UserResolver implements MutationInterface, AliasedInterface public function register(Argument $args, ResolveErrors $errors): User { $violations = $errors->getValidationErrors(); - + // ... } public static function getAliases(): array { return ['register' => 'register']; - } + } } ``` ## Error Messages -By default the `InputValidator` throws an `ArgumentsValidationException`, which will be caught and serialized into -a readable response. The [GraphQL specification](https://graphql.github.io/graphql-spec/June2018/#sec-Errors) defines a +By default the `InputValidator` throws an `ArgumentsValidationException`, which will be caught and serialized into +a readable response. The [GraphQL specification](https://graphql.github.io/graphql-spec/June2018/#sec-Errors) defines a certain shape of all errors returned in the response. According to it all validation violations are to be found under -the path `errors[index].extensions.validation` of the response object. +the path `errors[index].extensions.validation` of the response object. Example of a response with validation errors: @@ -972,17 +963,17 @@ Example of a response with validation errors: "validation": { "username": [ { - "message": "This value should be equal to 'Lorem Ipsum'.", + "message": "This value should be equal to 'Lorem Ipsum'.", "code": "478618a7-95ba-473d-9101-cabd45e49115" } ], "email": [ { - "message": "This value is not a valid email address.", + "message": "This value is not a valid email address.", "code": "bd79c0ab-ddba-46cc-a703-a7a4b08de310" }, { - "message": "This value is too short. It should have 5 character or more.", + "message": "This value is too short. It should have 5 character or more.", "code": "9ff3fdc4-b214-49db-8718-39c315e33d45" } ] @@ -999,14 +990,14 @@ Example of a response with validation errors: The codes in the response could be used to perform a client-side translation of the validation violations. ### Customizing the response -You can customize the output by passing `false` as a second argument to the `validate` method. +You can customize the output by passing `false` as a second argument to the `validate` method. This will prevent an exception to be thrown and a `ConstraintViolationList` object will be returned instead: ```php -public function resolver(InputValidator $validator) +public function resolver(InputValidator $validator) { $errors = $validator->validate(null, false); - + // Use $errors to build your own exception ... } @@ -1015,7 +1006,7 @@ See more about [Error handling](https://github.com/overblog/GraphQLBundle/blob/m ## Translations -All validation violations are automatically translated from the `validators` domain. +All validation violations are automatically translated from the `validators` domain. Example: ```yaml @@ -1038,7 +1029,7 @@ Mutation: password: type: String! validation: - - Length: + - Length: min: 8 max: 32 minMessage: "register.password.length.min" @@ -1046,7 +1037,7 @@ Mutation: - IdenticalTo: propertyPath: passwordRepeat message: "register.password.identical" - passwordRepeat: + passwordRepeat: type: String! ``` @@ -1077,19 +1068,19 @@ To translate into other languages just create additional translation resource w ## Using built-in expression functions -This bundle comes with a built-in [ExpressionLanguage](https://symfony.com/doc/current/components/expression_language.html) -instance and pre-registered [expression functions and variables](https://github.com/overblog/GraphQLBundle/blob/master/docs/definitions/expression-language.md). -By default the [`Expression`](https://symfony.com/doc/current/reference/constraints/Expression.html) -constraint in your project has no access to these functions and variables, because it uses the default instance of the +This bundle comes with a built-in [ExpressionLanguage](https://symfony.com/doc/current/components/expression_language.html) +instance and pre-registered [expression functions and variables](https://github.com/overblog/GraphQLBundle/blob/master/docs/definitions/expression-language.md). +By default the [`Expression`](https://symfony.com/doc/current/reference/constraints/Expression.html) +constraint in your project has no access to these functions and variables, because it uses the default instance of the `ExpressionLanguage` class. In order to _tell_ the `Expression` constraint to use the instance of this bundle, you need to rewrite its service declaration. Add the following config to the `services.yaml`: ```yaml -validator.expression: - class: Overblog\GraphQLBundle\Validator\Constraints\ExpressionValidator - arguments: ['@Overblog\GraphQLBundle\ExpressionLanguage\ExpressionLanguage'] - tags: - - name: validator.constraint_validator +validator.expression: + class: Overblog\GraphQLBundle\Validator\Constraints\ExpressionValidator + arguments: ['@Overblog\GraphQLBundle\ExpressionLanguage\ExpressionLanguage'] + tags: + - name: validator.constraint_validator alias: validator.expression ``` @@ -1114,8 +1105,8 @@ args: > **Note**: > > Expressions in the `Expression` constraint should NOT be prefixed with `@=`. -> As you might know, the `Expression` constraint has one built-in variable called [`value`](https://symfony.com/doc/current/reference/constraints/Expression.html#message). ->In order to avoid name conflicts, the resolver variable `value` is renamed to `parentValue`, when using in the +> As you might know, the `Expression` constraint has one built-in variable called [`value`](https://symfony.com/doc/current/reference/constraints/Expression.html#message). +>In order to avoid name conflicts, the resolver variable `value` is renamed to `parentValue`, when using in the >`Expression` constraint. > > In short: the `value` represents currently validated input data, and `parentValue` represents the data returned by the parent resolver. @@ -1128,24 +1119,24 @@ The ValidationNode class is used internally during the validation process. See t This class has methods that may be useful when using such constraints as `Callback` or `Expression`, which work in a context. ### Methods -getType(): GraphQL\Type\Definition\Type +getType(): GraphQL\Type\Definition\Type   Returns the `Type` object associated with current validation node. -getName(): string +getName(): string   Returns the name of the associated Type object. Shorthand for `getType()->name`. -getFieldName(): string|null -  Returns the field name if the object is associated with an `object` type, otherwise returns `null` +getFieldName(): string|null +  Returns the field name if the object is associated with an `object` type, otherwise returns `null` -getParent(): ValidationNode|null +getParent(): ValidationNode|null   Returns the parent node. -findParent(string $name): ValidationNode|null +findParent(string $name): ValidationNode|null   Traverses up through parent nodes and returns first object with matching name. ### Examples -#### Usage in the `Expression` constraints: +#### Usage in the `Expression` constraints: In this example we are checking if the value of the field `shownEmail` is contained in the `emails` array. We are using the method `getParent()` to access a field of the type `Mutation` from within the type `Profile`: ```yaml Mutation: @@ -1171,7 +1162,7 @@ Mutation: profile: type: Profile validation: cascade - + Profile: type: input-object config: @@ -1212,24 +1203,24 @@ Mutation: To find out which of 2 fields is being validated inside the method, we can use method `getFieldName`: ```php -namespace App\Validation; +namespace App\Validation; use Overblog\GraphQLBundle\Validator\ValidationNode; // ... public static function validate(ValidationNode $object, ExecutionContextInterface $context, $payload): void - { - switch ($object->getFieldName()) { - case 'createUser': - // Validation logic for users - break; - case 'createAdmin': - // Validation logic for admins - break; - default: - // Validation logic for all other fields - } + { + switch ($object->getFieldName()) { + case 'createUser': + // Validation logic for users + break; + case 'createAdmin': + // Validation logic for admins + break; + default: + // Validation logic for all other fields + } } // ... @@ -1248,6 +1239,6 @@ These are the validation constraints, which are not currently supported or have - [File](https://symfony.com/doc/current/reference/constraints/File.html) - not supported (_under development_) - [Image](https://symfony.com/doc/current/reference/constraints/Image.html) - not supported (_under development_) - [UniqueEntity](https://symfony.com/doc/current/reference/constraints/UniqueEntity.html) -- [Traverse](https://symfony.com/doc/current/reference/constraints/Traverse.html) - although you can use this constraint, -it would make no sense, as nested objects will be automatically validated with the `Valid` +- [Traverse](https://symfony.com/doc/current/reference/constraints/Traverse.html) - although you can use this constraint, +it would make no sense, as nested objects will be automatically validated with the `Valid` constraint. See [How does it work?](#how-does-it-work) section to get familiar with the internals. From 0000ed02f26fd9f90ed2173b9033096b30d88fa4 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Wed, 28 Jan 2026 19:05:28 +0100 Subject: [PATCH 31/33] fix cs and cleanup --- src/Generator/TypeBuilder.php | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/Generator/TypeBuilder.php b/src/Generator/TypeBuilder.php index 0a4afd7a1..b672acd4a 100644 --- a/src/Generator/TypeBuilder.php +++ b/src/Generator/TypeBuilder.php @@ -4,7 +4,6 @@ namespace Overblog\GraphQLBundle\Generator; -use Composer\InstalledVersions; use GraphQL\Language\AST\NodeKind; use GraphQL\Language\Parser; use GraphQL\Type\Definition\InputObjectType; @@ -95,7 +94,6 @@ public function __construct(ExpressionConverter $expressionConverter, string $na // Register additional converter in the php code generator Config::registerConverter($expressionConverter, ConverterInterface::TYPE_STRING); $this->isSymfony74Plus = class_exists(Video::class); - } /** @@ -642,19 +640,20 @@ private function buildConstraints(array $constraints = [], bool $inClosure = tru } if (is_array($args)) { - $reflectionClass = new ReflectionClass($fqcn); - $constructor = $reflectionClass->getConstructor(); - - if ($this->isSymfony74Plus && isset($args[0]) !== false && $fqcn === Choice::class) { + if ($this->isSymfony74Plus && false !== isset($args[0]) && Choice::class === $fqcn) { // Handle Choice constraint in Symfony 7.4+ - $args = ['choices'=>$args]; + $args = ['choices' => $args]; } /* * In Symfony 7.4+, we should not pass an array, but split up parameters in different arguments. */ - $inlineParameters = $this->isSymfony74Plus && isset($args[0]) === false; - if (null !== $constructor && $inlineParameters === true) { + if ($this->isSymfony74Plus && false === isset($args[0]) && [] !== $args) { + $reflectionClass = new ReflectionClass($fqcn); + $constructor = $reflectionClass->getConstructor(); + if (null === $constructor) { + throw new GeneratorException("Constraint '$fqcn' doesn't have a constructor."); + } $parameters = $constructor->getParameters(); foreach ($parameters as $parameter) { $name = $parameter->getName(); @@ -666,12 +665,10 @@ private function buildConstraints(array $constraints = [], bool $inClosure = tru throw new GeneratorException("Constraint '$fqcn' requires argument '$name'."); } } - } - - if (isset($args[0]) && is_array($args[0]) && false === $inlineParameters) { + } elseif (isset($args[0]) && is_array($args[0])) { // Nested instance $instance->addArgument($this->buildConstraints($args, false)); - } elseif (isset($args['constraints'][0]) && is_array($args['constraints'][0]) && false === $inlineParameters) { + } elseif (isset($args['constraints'][0]) && is_array($args['constraints'][0])) { // Nested instance with "constraints" key (full syntax) $options = [ 'constraints' => $this->buildConstraints($args['constraints'], false), @@ -686,7 +683,7 @@ private function buildConstraints(array $constraints = [], bool $inClosure = tru } $instance->addArgument($options); - } elseif (false === $inlineParameters) { + } else { // Numeric or Assoc array? $instance->addArgument(isset($args[0]) ? $args : Collection::assoc($args)); } From 480e1178abdda3ad536471ef5951f09e43bdf840 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Wed, 28 Jan 2026 19:48:37 +0100 Subject: [PATCH 32/33] collect coverage information from 2 jobs --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3baf6a5e2..69e08514e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,7 @@ jobs: - php-version: '8.4' symfony-version: '8.0.*' dependencies: 'highest' + coverage: 'pcov' - php-version: '8.2' symfony-version: '5.4.*' dependencies: 'lowest' From 7c2d0a45510abc3204031354ed26af45bc554986 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Wed, 28 Jan 2026 20:10:23 +0100 Subject: [PATCH 33/33] fix cs --- src/Generator/TypeBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Generator/TypeBuilder.php b/src/Generator/TypeBuilder.php index b672acd4a..134119b20 100644 --- a/src/Generator/TypeBuilder.php +++ b/src/Generator/TypeBuilder.php @@ -640,7 +640,7 @@ private function buildConstraints(array $constraints = [], bool $inClosure = tru } if (is_array($args)) { - if ($this->isSymfony74Plus && false !== isset($args[0]) && Choice::class === $fqcn) { + if ($this->isSymfony74Plus && isset($args[0]) && Choice::class === $fqcn) { // Handle Choice constraint in Symfony 7.4+ $args = ['choices' => $args]; }