Verified Commit c84246a8 authored by Dave Long's avatar Dave Long
Browse files

Issue #3461720 by bbrala, kunal.sachdev, longwave, penyaskito: Create AtLeastOneOf constraint

parent 82f500d0
Loading
Loading
Loading
Loading
Loading
+7 −0
Changes for core/lib/Drupal/Core/Validation/ExecutionContext.php: 7 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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;
  }

}
+40 −0
Changes for core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/AtLeastOneOfConstraint.php: 40 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?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]);
  }

}
+75 −0
Changes for core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/AtLeastOneOfConstraintValidator.php: 75 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?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();
  }

}
+115 −0
Changes for core/tests/Drupal/KernelTests/Core/Validation/AtLeastOneOfConstraintValidatorTest.php: 115 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?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.',
        ],
      ],
    ];
  }

}