Verified Commit 3981c8aa authored by Lee Rowlands's avatar Lee Rowlands
Browse files

Issue #2478663 by caesius, sanja_m, slashrsm, alphawebgroup, Drews_man, Sagar...

Issue #2478663 by caesius, sanja_m, slashrsm, alphawebgroup, Drews_man, Sagar Ramgade, Traverus, larowlan, smustgrave, Berdir, lauriii, catch, mbovan: UniqueFieldValueValidator works only with single value fields
parent b156dbda
Loading
Loading
Loading
Loading
+111 −23
Original line number Diff line number Diff line
@@ -2,50 +2,138 @@

namespace Drupal\Core\Validation\Plugin\Validation\Constraint;

use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;

/**
 * Validates that a field is unique for the given entity type.
 */
class UniqueFieldValueValidator extends ConstraintValidator {
class UniqueFieldValueValidator extends ConstraintValidator implements ContainerInjectionInterface {

  /**
   * Creates a UniqueFieldValueValidator object.
   *
   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $entityFieldManager
   *   The entity type manager.
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
   *   The entity type manager.
   */
  public function __construct(protected EntityFieldManagerInterface $entityFieldManager, protected EntityTypeManagerInterface $entityTypeManager) {
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('entity_field.manager'),
      $container->get('entity_type.manager')
    );
  }

  /**
   * {@inheritdoc}
   */
  public function validate($items, Constraint $constraint) {
    if (!$item = $items->first()) {
    if (!$items->first()) {
      return;
    }
    $field_name = $items->getFieldDefinition()->getName();

    /** @var \Drupal\Core\Entity\EntityInterface $entity */
    $entity = $items->getEntity();
    $entity_type_id = $entity->getEntityTypeId();
    $id_key = $entity->getEntityType()->getKey('id');
    $entity_type = $entity->getEntityType();
    $entity_type_id = $entity_type->id();
    $entity_label = $entity->getEntityType()->getSingularLabel();

    $field_name = $items->getFieldDefinition()->getName();
    $field_label = $items->getFieldDefinition()->getLabel();
    $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions($entity_type_id);
    $property_name = $field_storage_definitions[$field_name]->getMainPropertyName();

    $query = \Drupal::entityQuery($entity_type_id)
      ->accessCheck(FALSE);
    $id_key = $entity_type->getKey('id');
    $is_multiple = $field_storage_definitions[$field_name]->isMultiple();
    $is_new = $entity->isNew();
    $item_values = array_column($items->getValue(), $property_name);

    // Check if any item values for this field already exist in other entities.
    $query = $this->entityTypeManager
      ->getStorage($entity_type_id)
      ->getAggregateQuery()
      ->accessCheck(FALSE)
      ->condition($field_name, $item_values, 'IN')
      ->groupBy("$field_name.$property_name");
    if (!$is_new) {
      $entity_id = $entity->id();
    // Using isset() instead of !empty() as 0 and '0' are valid ID values for
    // entity types using string IDs.
    if (isset($entity_id)) {
      $query->condition($id_key, $entity_id, '<>');
    }
    $results = $query->execute();

    $value_taken = (bool) $query
      ->condition($field_name, $item->value)
      ->range(0, 1)
      ->count()
      ->execute();
    if (!empty($results)) {
      // The results array is a single-column multidimensional array. The
      // column key includes the field name but may or may not include the
      // property name. Pop the column key from the first result to be sure.
      $column_key = key(reset($results));
      $other_entity_values = array_column($results, $column_key);

    if ($value_taken) {
      $this->context->addViolation($constraint->message, [
        '%value' => $item->value,
        '@entity_type' => $entity->getEntityType()->getSingularLabel(),
        '@field_name' => $items->getFieldDefinition()->getLabel(),
      ]);
      // If our entity duplicates field values in any other entity, the query
      // will return all field values that belong to those entities. Narrow
      // down to only the specific duplicate values.
      $duplicate_values = array_intersect($item_values, $other_entity_values);

      foreach ($duplicate_values as $delta => $dupe) {
        $violation = $this->context
          ->buildViolation($constraint->message)
          ->setParameter('@entity_type', $entity_label)
          ->setParameter('@field_name', $field_label)
          ->setParameter('%value', $dupe);
        if ($is_multiple) {
          $violation->atPath($delta);
        }
        $violation->addViolation();
      }
    }

    // Check if items are duplicated within this entity.
    if ($is_multiple) {
      $duplicate_values = $this->extractDuplicates($item_values);
      foreach ($duplicate_values as $delta => $dupe) {
        $this->context
          ->buildViolation($constraint->message)
          ->setParameter('@entity_type', $entity_label)
          ->setParameter('@field_name', $field_label)
          ->setParameter('%value', $dupe)
          ->atPath($delta)
          ->addViolation();
      }
    }
  }

  /**
   * Get an array of duplicate field values.
   *
   * @param array $item_values
   *   The item values.
   *
   * @return array
   *   Item values only for deltas that duplicate an earlier delta.
   */
  private function extractDuplicates(array $item_values): array {
    $value_frequency = array_count_values($item_values);

    // Filter out item values which are not duplicates while preserving deltas
    $duplicate_values = array_intersect($item_values, array_keys(array_filter(
      $value_frequency, function ($value) {
        return $value > 1;
      })
    ));

    // Exclude the first delta of each duplicate value.
    $first_deltas = array_unique($duplicate_values);
    return array_diff_key($duplicate_values, $first_deltas);
  }

}
+1 −0
Original line number Diff line number Diff line
@@ -7,3 +7,4 @@ dependencies:
  - drupal:field
  - drupal:text
  - drupal:system
  - drupal:user
+51 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\entity_test\Entity;

use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;

/**
 * Defines a test entity class for unique constraint.
 *
 * @ContentEntityType(
 *   id = "entity_test_unique_constraint",
 *   label = @Translation("unique field entity"),
 *   handlers = {
 *     "view_builder" = "Drupal\entity_test\EntityTestViewBuilder",
 *     "access" = "Drupal\entity_test\EntityTestAccessControlHandler",
 *     "form" = {
 *       "default" = "Drupal\entity_test\EntityTestForm"
 *     },
 *     "translation" = "Drupal\content_translation\ContentTranslationHandler",
 *   },
 *   base_table = "entity_test_unique_constraint",
 *   data_table = "entity_test_unique_constraint_data",
 *   entity_keys = {
 *     "id" = "id",
 *     "uuid" = "uuid",
 *   },
 * )
 */
class EntityTestUniqueConstraint extends EntityTest {

  /**
   * {@inheritdoc}
   */
  public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
    $fields = parent::baseFieldDefinitions($entity_type);

    $fields['field_test_text'] = BaseFieldDefinition::create('string')
      ->setLabel(t('unique_field_test'))
      ->setCardinality(3)
      ->addConstraint('UniqueField');

    $fields['field_test_reference'] = BaseFieldDefinition::create('entity_reference')
      ->setLabel(t('unique_reference_test'))
      ->setCardinality(2)
      ->addConstraint('UniqueField')
      ->setSetting('target_type', 'user');
    return $fields;
  }

}
+311 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\KernelTests\Core\Validation;

use Drupal\KernelTests\KernelTestBase;
use Drupal\entity_test\Entity\EntityTestUniqueConstraint;
use Drupal\Tests\user\Traits\UserCreationTrait;

/**
 * Tests the unique field value validation constraint.
 *
 * @coversDefaultClass \Drupal\Core\Validation\Plugin\Validation\Constraint\UniqueFieldValueValidator
 *
 * @group Validation
 */
class UniqueValuesConstraintValidatorTest extends KernelTestBase {
  use UserCreationTrait;

  /**
   * {@inheritdoc}
   */
  protected static $modules = [
    'entity_test',
    'unique_field_constraint_test',
    'user',
  ];

  /**
   * Tests cases where the validation passes for entities with string IDs.
   *
   * @covers ::validate
   */
  protected function setUp(): void {
    parent::setUp();
    $this->setUpCurrentUser();
    $this->installEntitySchema('entity_test_unique_constraint');
  }

  /**
   * Tests the UniqueField validation constraint validator.
   *
   * Case 1. Try to create another entity with existing value for unique field.
   *
   * @throws \Drupal\Core\Entity\EntityStorageException
   *
   * @covers ::validate
   */
  public function testValidation() {
    // Create entity with two values for the testing field.
    $definition = [
      'id' => (int) rand(0, getrandmax()),
      'user_id' => 0,
      'field_test_text' => [
        'text1',
        'text2',
      ],
    ];
    $entity = EntityTestUniqueConstraint::create($definition);
    $violations = $entity->validate();
    $this->assertCount(0, $violations);
    $entity->save();
    $violations = $entity->validate();
    $this->assertCount(0, $violations);

    // Create another entity with two values for the testing field.
    $definition = [
      'id' => (int) rand(0, getrandmax()),
      'user_id' => 0,
      'field_test_text' => [
        'text3',
        'text4',
      ],
    ];
    $entity = EntityTestUniqueConstraint::create($definition);
    $violations = $entity->validate();
    $this->assertCount(0, $violations);
    $entity->save();
    $violations = $entity->validate();
    $this->assertCount(0, $violations);

    // Add existing value.
    $value = 'text1';
    $entity->get('field_test_text')->appendItem($value);
    $violations = $entity->validate();
    $this->assertCount(1, $violations);
    $this->assertEquals('field_test_text.2', $violations[0]->getPropertyPath());
    $this->assertEquals(t('A unique field entity with unique_field_test %value already exists.', [
      '%value' => $value,
    ]), $violations[0]->getMessage());

    // Create another entity with two values, but one value is existing.
    $definition = [
      'id' => (int) rand(0, getrandmax()),
      'user_id' => 0,
      'field_test_text' => [
        'text5',
        'text1',
      ],
    ];
    $entity = EntityTestUniqueConstraint::create($definition);
    $violations = $entity->validate();
    $this->assertCount(1, $violations);
    $this->assertEquals('field_test_text.1', $violations[0]->getPropertyPath());
    $this->assertEquals(t('A unique field entity with unique_field_test %value already exists.', [
      '%value' => $definition['field_test_text'][1],
    ]), $violations[0]->getMessage());

  }

  /**
   * Tests the UniqueField validation constraint validator for entity reference fields.
   *
   * Case 2. Try to create another entity with existing reference for unique field.
   *
   * @throws \Drupal\Core\Entity\EntityStorageException
   *
   * @covers ::validate
   */
  public function testValidationReference() {

    $users = [];
    for ($i = 0; $i <= 5; $i++) {
      $users[$i] = $this->createUser();
    }

    // Create new entity with two identical references.
    $definition = [
      'user_id' => 0,
      'field_test_reference' => [
        $users[0]->id(),
        $users[0]->id(),
      ],
    ];
    $entity = EntityTestUniqueConstraint::create($definition);
    $violations = $entity->validate();
    $this->assertCount(1, $violations);
    $this->assertEquals('field_test_reference.1', $violations[0]->getPropertyPath());
    $this->assertEquals(t('A unique field entity with unique_reference_test %value already exists.', [
      '%value' => $definition['field_test_reference'][1],
    ]), $violations[0]->getMessage());

    // Create entity with two references for the testing field.
    $definition = [
      'user_id' => 0,
      'field_test_reference' => [
        $users[1]->id(),
        $users[2]->id(),
      ],
    ];
    $entity = EntityTestUniqueConstraint::create($definition);
    $violations = $entity->validate();
    $this->assertCount(0, $violations);
    $entity->save();
    $violations = $entity->validate();
    $this->assertCount(0, $violations);

    // Create another entity with two references for the testing field.
    $definition = [
      'user_id' => 0,
      'field_test_reference' => [
        $users[3]->id(),
        $users[4]->id(),
      ],
    ];
    $entity = EntityTestUniqueConstraint::create($definition);
    $violations = $entity->validate();
    $this->assertCount(0, $violations);
    $entity->save();
    $violations = $entity->validate();
    $this->assertCount(0, $violations);

    // Create another entity with two references, but one reference is existing.
    $definition = [
      'user_id' => 0,
      'field_test_reference' => [
        $users[5]->id(),
        $users[1]->id(),
      ],
    ];
    $entity = EntityTestUniqueConstraint::create($definition);
    $violations = $entity->validate();
    $this->assertCount(1, $violations);
    $this->assertEquals('field_test_reference.1', $violations[0]->getPropertyPath());
    $this->assertEquals(t('A unique field entity with unique_reference_test %value already exists.', [
      '%value' => $definition['field_test_reference'][1],
    ]), $violations[0]->getMessage());

  }

  /**
   * Tests the UniqueField validation constraint validator for existing value in the same entity.
   *
   * Case 3. Try to add existing value for unique field in the same entity.
   *
   * @throws \Drupal\Core\Entity\EntityStorageException
   *
   * @covers ::validate
   */
  public function testValidationOwn() {
    // Create new entity with two identical values for the testing field.
    $definition = [
      'user_id' => 0,
      'field_test_text' => [
        'text0',
        'text0',
      ],
    ];
    $entity = EntityTestUniqueConstraint::create($definition);
    $violations = $entity->validate();
    $this->assertCount(1, $violations);
    $this->assertEquals('field_test_text.1', $violations[0]->getPropertyPath());
    $this->assertEquals(t('A unique field entity with unique_field_test %value already exists.', [
      '%value' => $definition['field_test_text'][1],
    ]), $violations[0]->getMessage());

    // Create entity with two different values for the testing field.
    $definition = [
      'user_id' => 0,
      'field_test_text' => [
        'text1',
        'text2',
      ],
    ];
    $entity = EntityTestUniqueConstraint::create($definition);
    $violations = $entity->validate();
    $this->assertCount(0, $violations);
    $entity->save();
    $violations = $entity->validate();
    $this->assertCount(0, $violations);

    // Add existing value.
    $entity->get('field_test_text')->appendItem($definition['field_test_text'][0]);
    $violations = $entity->validate();
    $this->assertCount(1, $violations);
    $this->assertEquals('field_test_text.2', $violations[0]->getPropertyPath());
    $this->assertEquals(t('A unique field entity with unique_field_test %value already exists.', [
      '%value' => $definition['field_test_text'][0],
    ]), $violations[0]->getMessage());

  }

  /**
   * Tests the UniqueField validation constraint validator for multiple violations.
   *
   * Case 4. Try to add multiple existing values for unique field in the same entity.
   *
   * @throws \Drupal\Core\Entity\EntityStorageException
   *
   * @covers ::validate
   */
  public function testValidationMultiple() {
    // Create entity with two different values for the testing field.
    $definition = [
      'user_id' => 0,
      'field_test_text' => [
        'multi0',
        'multi1',
      ],
    ];
    $entity = EntityTestUniqueConstraint::create($definition);
    $violations = $entity->validate();
    $this->assertCount(0, $violations);
    $entity->save();
    $violations = $entity->validate();
    $this->assertCount(0, $violations);

    // Create new entity with three identical values in unique field.
    $definition = [
      'user_id' => 0,
      'field_test_text' => [
        'multi2',
        'multi2',
        'multi2',
      ],
    ];
    $entity = EntityTestUniqueConstraint::create($definition);
    $violations = $entity->validate();
    $this->assertCount(2, $violations);
    $this->assertEquals('field_test_text.1', $violations[0]->getPropertyPath());
    $this->assertEquals(t('A unique field entity with unique_field_test %value already exists.', [
      '%value' => $definition['field_test_text'][1],
    ]), $violations[0]->getMessage());
    $this->assertEquals('field_test_text.2', $violations[1]->getPropertyPath());
    $this->assertEquals(t('A unique field entity with unique_field_test %value already exists.', [
      '%value' => $definition['field_test_text'][2],
    ]), $violations[1]->getMessage());

    // Create new entity with two identical values and one existing value in unique field.
    $definition = [
      'user_id' => 0,
      'field_test_text' => [
        'multi3',
        'multi1',
        'multi3',
      ],
    ];
    $entity = EntityTestUniqueConstraint::create($definition);
    $violations = $entity->validate();
    $this->assertCount(2, $violations);
    $this->assertEquals('field_test_text.1', $violations[0]->getPropertyPath());
    $this->assertEquals(t('A unique field entity with unique_field_test %value already exists.', [
      '%value' => $definition['field_test_text'][1],
    ]), $violations[0]->getMessage());
    $this->assertEquals('field_test_text.2', $violations[1]->getPropertyPath());
    $this->assertEquals(t('A unique field entity with unique_field_test %value already exists.', [
      '%value' => $definition['field_test_text'][2],
    ]), $violations[1]->getMessage());

  }

}