Verified Commit 20ac0fa9 authored by godotislate's avatar godotislate
Browse files

feat: #3513035 Add constraint to check that the max delta for a field item...

feat: #3513035 Add constraint to check that the max delta for a field item list does not exceed its cardinality

By: bbrala
By: anjaliprasannan
By: borisson_
By: smustgrave
By: larowlan
By: dcam
By: godotislate
parent 2cc48834
Loading
Loading
Loading
Loading
Loading
+4 −0
Changes for core/modules/field/config/schema/field.schema.yml: 4 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -46,6 +46,10 @@ field.storage.*.*:
    cardinality:
      type: integer
      label: 'Maximum number of values users can enter'
      constraints:
        NoFieldItemsExistWithHigherCardinality:
          entityType: '%parent.entity_type'
          fieldName: '%parent.field_name'
    translatable:
      type: boolean
      label: 'Translatable'
+30 −0
Changes for core/modules/field/src/Plugin/Validation/Constraint/NoFieldItemsExistWithHigherCardinality.php: 30 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\field\Plugin\Validation\Constraint;

use Drupal\Core\StringTranslation\TranslatableMarkup;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
use Drupal\Core\Validation\Attribute\Constraint;

/**
 * Checks if field items exist that exceed the allowed cardinality.
 */
#[Constraint(
  id: 'NoFieldItemsExistWithHigherCardinality',
  label: new TranslatableMarkup('No field items exist with higher cardinality', [], ['context' => 'Validation'])
)]
class NoFieldItemsExistWithHigherCardinality extends SymfonyConstraint {

  public function __construct(
    public string $entityType,
    public string $fieldName,
    public string $message = "The field '@field_name' of entity type '@entity_type' has more entries (@max_delta) than the cardinality (@cardinality) allows.",
    ?array $groups = NULL,
    mixed $payload = NULL,
  ) {
    parent::__construct(groups: $groups, payload: $payload);
  }

}
+110 −0
Changes for core/modules/field/src/Plugin/Validation/Constraint/NoFieldItemsExistWithHigherCardinalityValidator.php: 110 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\field\Plugin\Validation\Constraint;

use Drupal\Core\Config\Schema\TypeResolver;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\TypedData\TypedDataInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
use Symfony\Component\Validator\ConstraintValidator;

/**
 * Validates the NoFieldItemsExistWithHigherCardinality constraint.
 *
 * This validator checks whether existing field items of a specified field
 * exceed the given cardinality limit. It performs an aggregate query to find
 * the maximum delta (index of field items) for the specified field across all
 * entities of the given entity type, and compares it against the provided
 * cardinality.
 *
 * The validation:
 * - Skips if cardinality is unlimited (-1)
 * - Skips if the field storage configuration doesn't exist
 * - Uses EntityTypeManager to query the maximum field delta
 * - Adds a violation if the maximum delta exceeds the cardinality
 *
 * This validator implements ContainerInjectionInterface to access the entity
 * type manager service from the Drupal service container.
 *
 * @see \Drupal\field\Plugin\Validation\Constraint\NoFieldItemsExistWithHigherCardinality
 */
class NoFieldItemsExistWithHigherCardinalityValidator extends ConstraintValidator implements ContainerInjectionInterface {

  public function __construct(
    protected EntityTypeManagerInterface $entityTypeManager,
  ) {
  }

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

  /**
   * {@inheritdoc}
   */
  public function validate(mixed $value, SymfonyConstraint $constraint): void {
    assert($constraint instanceof NoFieldItemsExistWithHigherCardinality);

    // Cardinality should be an int, but could be passed differently.
    $cardinality = (int) $value;

    if ($cardinality === FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED) {
      return;
    }

    $object = $this->context->getObject();
    assert($object instanceof TypedDataInterface);

    $entity_type = TypeResolver::resolveExpression($constraint->entityType, $object);
    $field_name = TypeResolver::resolveExpression($constraint->fieldName, $object);

    // We cannot check this constraint if the field storage does not exist.
    $fieldStorageConfig = $this->entityTypeManager->getStorage('field_storage_config')
      ->load($entity_type . '.' . $field_name);
    if ($fieldStorageConfig === NULL) {
      return;
    }

    if ($fieldStorageConfig->hasCustomStorage()) {
      // If the field storage has custom storage, we cannot check this
      // constraint.
      return;
    }

    $max_delta_alias = 'max_delta';
    $query = $this->entityTypeManager->getStorage($entity_type)
      ->getAggregateQuery()
      ->aggregate($field_name . '.%delta', 'MAX', NULL, $max_delta_alias)
      ->accessCheck(FALSE);
    $result = $query->execute();

    $max_delta = 0;
    if (is_array($result) && !empty($result)) {
      if ($result[0][$max_delta_alias] !== NULL) {
        // Delta starts at 0, so we need to add 1 to get the count of
        // existing values.
        $max_delta = (int) $result[0][$max_delta_alias] + 1;
      }
    }

    if ($max_delta > $cardinality) {
      $this->context->addViolation($constraint->message, [
        '@entity_type' => $entity_type,
        '@field_name' => $field_name,
        '@max_delta' => $max_delta,
        '@cardinality' => $cardinality,
      ]);
    }
  }

}
+58 −0
Changes for core/modules/field/tests/src/Kernel/Plugin/Validation/Constraint/NoFieldItemsExistWithHigherCardinalityTest.php: 58 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\Tests\field\Kernel\Plugin\Validation\Constraint;

use Drupal\Core\Config\Schema\SchemaIncompleteException;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\field\Plugin\Validation\Constraint\NoFieldItemsExistWithHigherCardinalityValidator;
use Drupal\Tests\field\Kernel\FieldKernelTestBase;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;

/**
 * Tests NoFieldItemsExistWithHigherCardinality validation.
 */
#[CoversClass(NoFieldItemsExistWithHigherCardinalityValidator::class)]
#[Group('field')]
#[RunTestsInSeparateProcesses]
class NoFieldItemsExistWithHigherCardinalityTest extends FieldKernelTestBase {

  /**
   * Tests validation error and message when cardinality is set too low.
   */
  public function testValidation(): void {
    // Create a field with a cardinality of 2 to show that we are counting
    // entities and not rows in a table.
    /** @var \Drupal\field\Entity\FieldStorageConfig $field_storage */
    $field_storage = FieldStorageConfig::create([
      'field_name' => 'field_int',
      'entity_type' => 'entity_test',
      'type' => 'integer',
      'cardinality' => 2,
    ]);
    $field_storage->save();
    $field_config = FieldConfig::create([
      'field_storage' => $field_storage,
      'bundle' => 'entity_test',
    ]);
    $field_config->save();

    $entity = EntityTest::create();
    $entity->field_int[] = mt_rand(1, 99);
    $entity->field_int[] = mt_rand(1, 99);
    $entity->name[] = $this->randomMachineName();
    $entity->save();

    $field_storage->set('cardinality', 1);

    $this->expectException(SchemaIncompleteException::class);
    $this->expectExceptionMessage('Schema errors for field.storage.entity_test.field_int with the following errors: 0 [cardinality] The field &#039;field_int&#039; of entity type &#039;entity_test&#039; has more entries (2) than the cardinality (1) allows');
    $field_storage->save();
  }

}
+85 −0
Changes for core/modules/field/tests/src/Unit/Plugin/Validation/Constraint/NoFieldItemsExistWithHigherCardinalityTest.php: 85 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\Tests\field\Unit\Plugin\Validation\Constraint;

use Drupal\field\Plugin\Validation\Constraint\NoFieldItemsExistWithHigherCardinality;
use Drupal\Tests\UnitTestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;

/**
 * Tests the NoFieldItemsExistWithHigherCardinality constraint.
 */
#[CoversClass(NoFieldItemsExistWithHigherCardinality::class)]
#[Group('field')]
class NoFieldItemsExistWithHigherCardinalityTest extends UnitTestCase {

  /**
   * Tests the constraint initialization with valid options.
   */
  public function testValidOptions(): void {
    $options = [
      'entityType' => 'node',
      'fieldName' => 'field_test',
    ];

    $constraint = new NoFieldItemsExistWithHigherCardinality(entityType: $options['entityType'], fieldName: $options['fieldName']);

    $this->assertEquals('node', $constraint->entityType);
    $this->assertEquals('field_test', $constraint->fieldName);
    $this->assertEquals(
      "The field '@field_name' of entity type '@entity_type' has more entries (@max_delta) than the cardinality (@cardinality) allows.",
      $constraint->message
    );
  }

  /**
   * Tests the message template with different parameters.
   */
  #[DataProvider('messageParametersProvider')]
  public function testMessageParameters(string $entityType, string $fieldName, int $maxDelta, int $cardinality, string $expectedMessage): void {
    $options = [
      'entityType' => $entityType,
      'fieldName' => $fieldName,
    ];

    $constraint = new NoFieldItemsExistWithHigherCardinality(entityType: $options['entityType'], fieldName: $options['fieldName']);

    // Simulate the violation building process.
    $parameters = [
      '@field_name' => $fieldName,
      '@entity_type' => $entityType,
      '@max_delta' => (string) $maxDelta,
      '@cardinality' => (string) $cardinality,
    ];

    $message = strtr($constraint->message, $parameters);
    $this->assertEquals($expectedMessage, $message);
  }

  /**
   * Data provider for testMessageParameters.
   */
  public static function messageParametersProvider(): array {
    return [
      [
        'node',
        'field_body',
        3,
        2,
        "The field 'field_body' of entity type 'node' has more entries (3) than the cardinality (2) allows.",
      ],
      [
        'user',
        'field_address',
        5,
        1,
        "The field 'field_address' of entity type 'user' has more entries (5) than the cardinality (1) allows.",
      ],
    ];
  }

}