diff --git a/core/lib/Drupal/Core/Validation/ExecutionContext.php b/core/lib/Drupal/Core/Validation/ExecutionContext.php index 5e67e0e786f03901ecdf9b5b852dd1e503cd226a..150c5a7b96548e58bd360f440a5328d4f97179bb 100644 --- a/core/lib/Drupal/Core/Validation/ExecutionContext.php +++ b/core/lib/Drupal/Core/Validation/ExecutionContext.php @@ -241,4 +241,11 @@ public function isObjectInitialized(string $cacheKey): bool { throw new \LogicException(ExecutionContextInterface::class . '::isObjectInitialized is unsupported.'); } + /** + * Clone this context. + */ + public function __clone(): void { + $this->violations = clone $this->violations; + } + } diff --git a/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/AtLeastOneOfConstraint.php b/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/AtLeastOneOfConstraint.php new file mode 100644 index 0000000000000000000000000000000000000000..de99cd9985aeb76d0fd26c748a5e0c779b6336b7 --- /dev/null +++ b/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/AtLeastOneOfConstraint.php @@ -0,0 +1,40 @@ +<?php + +namespace Drupal\Core\Validation\Plugin\Validation\Constraint; + +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\Validation\Attribute\Constraint; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\Validator\Constraint as SymfonyConstraint; +use Symfony\Component\Validator\Constraints\AtLeastOneOf; + +/** + * Checks that at least one of the given constraint is satisfied. + * + * Overrides the symfony constraint to convert the array of constraints to array + * of constraint objects and use them. + */ +#[Constraint( + id: 'AtLeastOneOf', + label: new TranslatableMarkup('At least one of', [], ['context' => 'Validation']) +)] +class AtLeastOneOfConstraint extends AtLeastOneOf implements ContainerFactoryPluginInterface { + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + $constraint_manager = $container->get('validation.constraint'); + $constraints = $configuration['constraints']; + $constraint_instances = []; + foreach ($constraints as $constraint_id => $constraint) { + foreach ($constraint as $constraint_name => $constraint_options) { + $constraint_instances[$constraint_id] = $constraint_manager->create($constraint_name, $constraint_options); + } + } + + return new static($constraint_instances, [SymfonyConstraint::DEFAULT_GROUP]); + } + +} diff --git a/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/AtLeastOneOfConstraintValidator.php b/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/AtLeastOneOfConstraintValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..15a5cbb5d79338c675f24398185b94dee1ddbe10 --- /dev/null +++ b/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/AtLeastOneOfConstraintValidator.php @@ -0,0 +1,75 @@ +<?php + +namespace Drupal\Core\Validation\Plugin\Validation\Constraint; + +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\All; +use Symfony\Component\Validator\Constraints\Collection; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; + +/** + * Validates the AtLeastOneOf constraint. + */ +class AtLeastOneOfConstraintValidator extends ConstraintValidator { + + /** + * Validate a set of constraints against a value. + * + * This validator method is a copy of Symfony's AtLeastOneOf constraint. This + * is necessary because Drupal does not support validation groups. + * + * @param mixed $value + * The value to validate. + * @param \Symfony\Component\Validator\Constraint $constraint + * The constraint to validate against. + */ + public function validate(mixed $value, Constraint $constraint): void { + if (!$constraint instanceof AtLeastOneOfConstraint) { + throw new UnexpectedTypeException($constraint, AtLeastOneOfConstraint::class); + } + + $validator = $this->context->getValidator(); + + // Build a first violation to have the base message of the constraint. + $baseMessageContext = clone $this->context; + $baseMessageContext->buildViolation($constraint->message)->addViolation(); + $baseViolations = $baseMessageContext->getViolations(); + $messages = [(string) $baseViolations->get(\count($baseViolations) - 1)->getMessage()]; + + foreach ($constraint->constraints as $key => $item) { + $context_group = $this->context->getGroup(); + if (!\in_array($context_group, $item->groups, TRUE)) { + continue; + } + + $context = $this->context; + $executionContext = clone $this->context; + $executionContext->setNode($value, $this->context->getObject(), $this->context->getMetadata(), $this->context->getPropertyPath()); + $violations = $validator->inContext($executionContext)->validate($context->getObject(), $item/*, $context_group*/)->getViolations(); + $this->context = $context; + + if (\count($this->context->getViolations()) === \count($violations)) { + return; + } + + if ($constraint->includeInternalMessages) { + $message = ' [' . ($key + 1) . '] '; + + if ($item instanceof All || $item instanceof Collection) { + $message .= $constraint->messageCollection; + } + else { + $message .= $violations->get(\count($violations) - 1)->getMessage(); + } + + $messages[] = $message; + } + } + + $this->context + ->buildViolation(implode('', $messages)) + ->addViolation(); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Validation/AtLeastOneOfConstraintValidatorTest.php b/core/tests/Drupal/KernelTests/Core/Validation/AtLeastOneOfConstraintValidatorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..1afd6b2124ef2786d5a505a1d99fb8303ea575ee --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Validation/AtLeastOneOfConstraintValidatorTest.php @@ -0,0 +1,115 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Validation; + +use Drupal\Core\TypedData\DataDefinition; +use Drupal\KernelTests\KernelTestBase; + +/** + * Tests AtLeastOneOf validation constraint with both valid and invalid values. + * + * @covers \Drupal\Core\Validation\Plugin\Validation\Constraint\AtLeastOneOfConstraint + * @covers \Drupal\Core\Validation\Plugin\Validation\Constraint\AtLeastOneOfConstraintValidator + * + * @group Validation + */ +class AtLeastOneOfConstraintValidatorTest extends KernelTestBase { + + /** + * The typed data manager to use. + * + * @var \Drupal\Core\TypedData\TypedDataManager + */ + protected $typedData; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->typedData = $this->container->get('typed_data_manager'); + } + + /** + * Tests the AllowedValues validation constraint validator. + * + * For testing we define an integer with a set of allowed values. + * + * @dataProvider dataProvider + */ + public function testValidation($type, $value, $at_least_one_of_constraints, $expectedViolations, $extra_constraints = []): void { + // Create a definition that specifies some AllowedValues. + $definition = DataDefinition::create($type); + + if (count($extra_constraints) > 0) { + foreach ($extra_constraints as $name => $settings) { + $definition->addConstraint($name, $settings); + } + } + + $definition->addConstraint('AtLeastOneOf', [ + 'constraints' => $at_least_one_of_constraints, + ]); + + // Test the validation. + $typed_data = $this->typedData->create($definition, $value); + $violations = $typed_data->validate(); + + $violationMessages = []; + foreach ($violations as $violation) { + $violationMessages[] = (string) $violation->getMessage(); + } + + $this->assertEquals($expectedViolations, $violationMessages, 'Validation passed for correct value.'); + } + + /** + * Data provider for testValidation(). + */ + public static function dataProvider(): array { + return [ + 'It should fail on a failing sibling validator' => [ + 'integer', + 1, + [ + ['Range' => ['min' => 100]], + ['NotNull' => []], + ], + ['This value should be blank.'], + ['Blank' => []], + ], + 'it should not fail if first validator fails' => [ + 'integer', + 250, + [ + ['AllowedValues' => [500]], + ['Range' => ['min' => 100]], + ], + [], + ], + 'it should not fail if second validator fails' => [ + 'integer', + 250, + [ + ['Range' => ['min' => 100]], + ['AllowedValues' => [500]], + ], + [], + ], + 'it should show multiple validation errors if none validate' => [ + 'string', + 'Green', + [ + ['AllowedValues' => ['test']], + ['Blank' => []], + ], + [ + 'This value should satisfy at least one of the following constraints: [1] The value you selected is not a valid choice. [2] This value should be blank.', + ], + ], + ]; + } + +}