diff --git a/core/modules/field/config/schema/field.schema.yml b/core/modules/field/config/schema/field.schema.yml index cdd1d4bab05cbeba55c7f2abc1aa80a567ded688..26f98da302f203ca3a1e6ffde1b6f232b05ccb7b 100644 --- a/core/modules/field/config/schema/field.schema.yml +++ b/core/modules/field/config/schema/field.schema.yml @@ -45,6 +45,10 @@ field.storage.*.*: cardinality: type: integer label: 'Maximum number of values users can enter' + constraints: + NoEntitiesExistYetWithHigherCardinality: + entityType: '%parent.entity_type' + fieldName: '%parent.field_name' translatable: type: boolean label: 'Translatable' diff --git a/core/modules/field/src/Plugin/Validation/Constraint/NoEntitiesExistYetWithHigherCardinality.php b/core/modules/field/src/Plugin/Validation/Constraint/NoEntitiesExistYetWithHigherCardinality.php new file mode 100644 index 0000000000000000000000000000000000000000..88b976e568acf8414934e47dc15ac14c4563e1d9 --- /dev/null +++ b/core/modules/field/src/Plugin/Validation/Constraint/NoEntitiesExistYetWithHigherCardinality.php @@ -0,0 +1,48 @@ +<?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 an entity with a higher cardinality than specified exists. + */ +#[Constraint( + id: 'NoEntitiesExistYetWithHigherCardinality', + label: new TranslatableMarkup('No entities exist with higher cardinality', [], ['context' => 'Validation']) +)] +class NoEntitiesExistYetWithHigherCardinality extends SymfonyConstraint { + + /** + * The error message if an entity with a higher cardinality exists. + * + * @var string + */ + public string $message = "The field '@field_name' of entity type '@entity_type' has more entries (@max_delta) than the cardinality (@cardinality) allows."; + + /** + * The entity type to check. + * + * @var string + */ + public string $entityType; + + /** + * The field name to check. + * + * @var string + */ + public string $fieldName; + + /** + * {@inheritdoc} + */ + public function getRequiredOptions(): array { + return ['entityType', 'fieldName']; + } + +} diff --git a/core/modules/field/src/Plugin/Validation/Constraint/NoEntitiesExistYetWithHigherCardinalityValidator.php b/core/modules/field/src/Plugin/Validation/Constraint/NoEntitiesExistYetWithHigherCardinalityValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..e2dd2dbf5b5259dc2e47916676205341d7e215a0 --- /dev/null +++ b/core/modules/field/src/Plugin/Validation/Constraint/NoEntitiesExistYetWithHigherCardinalityValidator.php @@ -0,0 +1,114 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\field\Plugin\Validation\Constraint; + +use Drupal\Core\Config\Schema\TypeResolver; +use Drupal\Core\Database\DatabaseExceptionWrapper; +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 NoEntitiesExistYetWithHigherCardinality constraint. + * + * This validator checks whether existing entities of a specified type have more + * field values than allowed by the given cardinality limit. It performs an + * aggregate query to find the maximum delta (number of field values) for the + * specified field across all entities of the given 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\NoEntitiesExistYetWithHigherCardinality + */ +class NoEntitiesExistYetWithHigherCardinalityValidator extends ConstraintValidator implements ContainerInjectionInterface { + + public function __construct( + protected EntityTypeManagerInterface $entityTypeManager, + ) { + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container): self { + return new static( + $container->get('entity_type.manager') + ); + } + + /** + * {@inheritdoc} + */ + public function validate(mixed $cardinality, SymfonyConstraint $constraint): void { + assert($constraint instanceof NoEntitiesExistYetWithHigherCardinality); + + 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() + ->accessCheck(FALSE) + ->aggregate($field_name . '.%delta', 'MAX', NULL, $max_delta_alias); + + // When the schema for the entity does not exist the query will throw an + // exception. This should only happen in tests. + // @see https://www.drupal.org/node/3475719 + // @todo Remove in Drupal 12. + try { + $result = $query->execute(); + } + catch (DatabaseExceptionWrapper) { + return; + } + + $max_delta = 0; + if (is_array($result) && !empty($result)) { + $max_delta = $result[0][$max_delta_alias] ?? 0; + } + + if ($max_delta > $cardinality) { + $this->context->addViolation($constraint->message, [ + '@entity_type' => $entity_type, + '@field_name' => $field_name, + '@max_delta' => $max_delta, + '@cardinality' => $cardinality, + ]); + } + } + +} diff --git a/core/modules/field/tests/src/Unit/Plugin/Validation/Constraint/NoEntitiesExistYetWithHigherCardinalityTest.php b/core/modules/field/tests/src/Unit/Plugin/Validation/Constraint/NoEntitiesExistYetWithHigherCardinalityTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a612d4c4a726c9a9df9414b7242733fef6219c16 --- /dev/null +++ b/core/modules/field/tests/src/Unit/Plugin/Validation/Constraint/NoEntitiesExistYetWithHigherCardinalityTest.php @@ -0,0 +1,124 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\field\Unit\Plugin\Validation\Constraint; + +use Drupal\field\Plugin\Validation\Constraint\NoEntitiesExistYetWithHigherCardinality; +use Drupal\Tests\UnitTestCase; +use Symfony\Component\Validator\Exception\MissingOptionsException as MissingOptionsExceptionAlias; + +/** + * Tests the NoEntitiesExistYetWithHigherCardinality constraint. + * + * @group field + */ +class NoEntitiesExistYetWithHigherCardinalityTest extends UnitTestCase { + + /** + * Tests the constraint's required options. + */ + public function testRequiredOptions(): void { + $options = [ + 'entityType' => 'node', + 'fieldName' => 'field_test', + ]; + $constraint = new NoEntitiesExistYetWithHigherCardinality($options); + $requiredOptions = $constraint->getRequiredOptions(); + + $this->assertTrue(is_array($requiredOptions)); + $this->assertEquals(['entityType', 'fieldName'], $requiredOptions); + } + + /** + * Tests the constraint initialization with valid options. + */ + public function testValidOptions(): void { + $options = [ + 'entityType' => 'node', + 'fieldName' => 'field_test', + ]; + + $constraint = new NoEntitiesExistYetWithHigherCardinality($options); + + $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 constraint initialization with missing required options. + */ + public function testMissingOptions(): void { + $this->expectException(MissingOptionsExceptionAlias::class); + $this->expectExceptionMessage('The options "entityType" must be set for constraint'); + + new NoEntitiesExistYetWithHigherCardinality(['fieldName' => 'field_test']); + } + + /** + * Tests the constraint's default configuration. + */ + public function testDefaultConfiguration(): void { + $options = [ + 'entityType' => 'user', + 'fieldName' => 'field_example', + ]; + + $constraint = new NoEntitiesExistYetWithHigherCardinality($options); + + $defaultConfig = $constraint->getDefaultOption(); + $this->assertNull($defaultConfig); + } + + /** + * 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 NoEntitiesExistYetWithHigherCardinality($options); + + // 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.", + ], + ]; + } + +}