diff --git a/core/lib/Drupal/Core/Entity/ContentEntityBase.php b/core/lib/Drupal/Core/Entity/ContentEntityBase.php index ee965898..129e99fe 100644 --- a/core/lib/Drupal/Core/Entity/ContentEntityBase.php +++ b/core/lib/Drupal/Core/Entity/ContentEntityBase.php @@ -146,6 +146,13 @@ abstract class ContentEntityBase extends EntityBase implements \IteratorAggregat */ protected $validated = FALSE; + /** + * A flag indicating whether the entity is being validated. + * + * @var bool + */ + protected $validationRunning = FALSE; + /** * Whether entity validation is required before saving the entity. * @@ -485,9 +492,34 @@ public function postSave(EntityStorageInterface $storage, $update = TRUE) { * {@inheritdoc} */ public function validate() { + $this->validationRunning = TRUE; $this->validated = TRUE; $violations = $this->getTypedData()->validate(); - return new EntityConstraintViolationList($this, iterator_to_array($violations)); + $violation_list = new EntityConstraintViolationList($this, iterator_to_array($violations)); + $this->validationRunning = FALSE; + return $violation_list; + } + + /** + * {@inheritdoc} + */ + public function isValidationRunning() { + return $this->validationRunning; + } + + /** + * {@inheritdoc} + */ + public function isValidated() { + return $this->validated; + } + + /** + * {@inheritdoc} + */ + public function setValidated($validated) { + $this->validated = $validated; + return $this; } /** @@ -502,6 +534,9 @@ public function isValidationRequired() { */ public function setValidationRequired($required) { $this->validationRequired = $required; + if ($required) { + $this->setValidated(FALSE); + } return $this; } @@ -840,6 +875,10 @@ public function onChange($name) { $this->setRevisionTranslationAffectedEnforced(TRUE); break; } + + // If entity was already validated before, isValidated() will return true + // and validation would be skipped. To avoid this, we reset this flag. + $this->setValidated(FALSE); } /** diff --git a/core/lib/Drupal/Core/Entity/FieldableEntityInterface.php b/core/lib/Drupal/Core/Entity/FieldableEntityInterface.php index dc2a6ea1..d20dd2de 100644 --- a/core/lib/Drupal/Core/Entity/FieldableEntityInterface.php +++ b/core/lib/Drupal/Core/Entity/FieldableEntityInterface.php @@ -218,6 +218,33 @@ public function onChange($field_name); */ public function validate(); + /** + * Checks whether entity validation is currently running. + * + * @return bool + * TRUE if the validation is running, FALSE if not. + */ + public function isValidationRunning(); + + /** + * Checks whether the entity has been validated. + * + * @return bool + * TRUE if the entity has been validated, FALSE if not. + */ + public function isValidated(); + + /** + * Sets the validated flag. + * + * @param bool $validated + * If set to TRUE the entity will be flagged as validated, otherwise as not + * validated. + * + * @return $this + */ + public function setValidated($validated); + /** * Checks whether entity validation is required before saving the entity. * diff --git a/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/ValidReferenceConstraint.php b/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/ValidReferenceConstraint.php index 34c6ee54..12556e6b 100644 --- a/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/ValidReferenceConstraint.php +++ b/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/ValidReferenceConstraint.php @@ -37,6 +37,13 @@ class ValidReferenceConstraint extends Constraint { */ public $invalidAutocreateMessage = 'This entity (%type: %label) cannot be referenced.'; + /** + * Violation message when a new entity doesn't pass the validation. + * + * @var string + */ + public $invalidNewEntity = 'This entity (%type: %label) cannot be created. Reason(s): "%reason".'; + /** * Violation message when the target_id is empty. * diff --git a/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/ValidReferenceConstraintValidator.php b/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/ValidReferenceConstraintValidator.php index aef9fb00..0ab6c694 100644 --- a/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/ValidReferenceConstraintValidator.php +++ b/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/ValidReferenceConstraintValidator.php @@ -6,6 +6,8 @@ use Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginManagerInterface; use Drupal\Core\Entity\EntityReferenceSelection\SelectionWithAutocreateInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Entity\FieldableEntityInterface; +use Drupal\Core\Field\EntityReferenceFieldItemList; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; @@ -91,25 +93,85 @@ public function validate($value, Constraint $constraint) { return; } - $entity = !empty($value->getParent()) ? $value->getEntity() : NULL; + $parent_entity = !empty($value->getParent()) ? $value->getEntity() : NULL; /** @var \Drupal\Core\Entity\EntityReferenceSelection\SelectionInterface $handler * */ - $handler = $this->selectionManager->getSelectionHandler($value->getFieldDefinition(), $entity); + $handler = $this->selectionManager->getSelectionHandler($value->getFieldDefinition(), $parent_entity); $target_type_id = $value->getFieldDefinition()->getSetting('target_type'); // Add violations on deltas with a new entity that is not valid. if ($new_entities) { if ($handler instanceof SelectionWithAutocreateInterface) { - $valid_new_entities = $handler->validateReferenceableNewEntities($new_entities); - $invalid_new_entities = array_diff_key($new_entities, $valid_new_entities); + $referenceable_new_entities = $handler->validateReferenceableNewEntities($new_entities); + $non_referenceable_new_entities = array_diff_key($new_entities, $referenceable_new_entities); + + // The entities which could be created should run through the entity + // validation before being saved. + foreach ($referenceable_new_entities as $delta => $entity) { + if ($entity instanceof FieldableEntityInterface) { + // If a referenced entity is currently being validated then we've + // encountered a cyclic reference and therefore should skip starting + // the validation of the entity again in order to prevent an endless + // loop. + if ($entity->isValidationRunning()) { + continue; + } + + // If the referenced entity has been validated somewhere else then + // we shouldn't validate it again, as code validating the entity and + // leaving the validated flag set to TRUE is required of taking care + // of the validation errors. If we ignore the fact that the entity + // is already validated, then we might show the same errors multiple + // times. In order to prevent this whoever is validating the entity + // is also responsible for showing the errors or resetting the + // validated flag of the entity. + // @see \Drupal\Core\Entity\FieldableEntityInterface::validate() + // @see \Drupal\Core\Entity\FieldableEntityInterface::setValidated() + if ($entity->isValidated()) { + continue; + } + $violations = $entity->validate(); + } + else { + $violations = $entity->getTypedData()->validate(); + } + + if ($violations->count() !== 0) { + $entity_type = $entity->getEntityType(); + $type_label = $entity_type->getLabel(); + // If the entity supports bundles, then add the bundle label to the + // type label. + if ($entity_type->hasKey('bundle')) { + /** @var \Drupal\Core\Field\FieldItemListInterface $bundle_field */ + $bundle_field = $entity->{$entity_type->getKey('bundle')}; + if ($bundle_field instanceof EntityReferenceFieldItemList) { + $type_label .= ' ' . $bundle_field->entity->label(); + } + else { + $type_label .= ' ' . $bundle_field->getString(); + } + } + /** @var \Symfony\Component\Validator\ConstraintViolationInterface $violation */ + foreach ($violations as $violation) { + // Add the reason for the validation failure to the current context. + $this->context->buildViolation($constraint->invalidNewEntity) + ->setParameter('%type', $type_label) + ->setParameter('%label', $entity->label()) + ->setParameter('%reason', $violation->getMessage()) + ->atPath($delta . '.entity.' . $violation->getPropertyPath()) + ->setInvalidValue($violation->getInvalidValue()) + ->addViolation(); + } + } + } } else { // If the selection handler does not support referencing newly created // entities, all of them should be invalidated. - $invalid_new_entities = $new_entities; + $non_referenceable_new_entities = $new_entities; } - foreach ($invalid_new_entities as $delta => $entity) { + foreach ($non_referenceable_new_entities as $delta => $entity) { $this->context->buildViolation($constraint->invalidAutocreateMessage) ->setParameter('%type', $target_type_id) ->setParameter('%label', $entity->label()) diff --git a/core/modules/field/tests/src/Kernel/EntityReference/EntityReferenceItemTest.php b/core/modules/field/tests/src/Kernel/EntityReference/EntityReferenceItemTest.php index 027d0eb7..a18121d5 100644 --- a/core/modules/field/tests/src/Kernel/EntityReference/EntityReferenceItemTest.php +++ b/core/modules/field/tests/src/Kernel/EntityReference/EntityReferenceItemTest.php @@ -101,12 +101,16 @@ protected function setUp(): void { $this->term->save(); NodeType::create([ - 'type' => $this->randomMachineName(), + 'type' => 'node', ])->save(); CommentType::create([ 'id' => $this->randomMachineName(), 'target_entity_type_id' => 'node', ])->save(); + CommentType::create([ + 'id' => 'comment', + 'target_entity_type_id' => 'entity_test', + ])->save(); $this->entityStringId = EntityTestStringId::create([ 'id' => $this->randomMachineName(), @@ -528,6 +532,7 @@ public function testAutocreateValidation() { 'subject' => $title, 'comment_type' => 'comment', 'status' => 0, + 'field_name' => 'field_test_comment', ]); $entity = EntityTest::create([ @@ -536,6 +541,12 @@ public function testAutocreateValidation() { ], ]); + // Attach the entity and entity type this comments belongs to, otherwise + // the validation will fail as the entity_id and entity_type fields + // are required. + $comment->set('entity_id', ['entity' => $entity]); + $comment->set('entity_type', $entity->getEntityTypeId()); + $errors = $entity->validate(); $this->assertCount(1, $errors); $this->assertEquals(new FormattableMarkup('This entity (%type: %label) cannot be referenced.', ['%type' => 'comment', '%label' => $title]), $errors[0]->getMessage()); @@ -547,10 +558,11 @@ public function testAutocreateValidation() { $this->assertCount(0, $errors); // Test with an inactive and unsaved user. - $name = $this->randomString(); + $name = 'test user'; $user = User::create([ 'name' => $name, 'status' => 0, + 'mail' => 'user@example.com', ]); $entity = EntityTest::create([ diff --git a/core/tests/Drupal/KernelTests/Core/Entity/ContentEntityCloneTest.php b/core/tests/Drupal/KernelTests/Core/Entity/ContentEntityCloneTest.php index 252fdf76..b7c92d5a 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/ContentEntityCloneTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/ContentEntityCloneTest.php @@ -297,7 +297,26 @@ public function testEntityPropertiesModifications() { // Retrieve the entity properties. $reflection = new \ReflectionClass($entity); $properties = $reflection->getProperties(~\ReflectionProperty::IS_STATIC); - $translation_unique_properties = ['activeLangcode', 'translationInitialize', 'fieldDefinitions', 'languages', 'langcodeKey', 'defaultLangcode', 'defaultLangcodeKey', 'revisionTranslationAffectedKey', 'validated', 'validationRequired', 'entityTypeId', 'typedData', 'cacheContexts', 'cacheTags', 'cacheMaxAge', '_serviceIds', '_entityStorages']; + $translation_unique_properties = [ + 'activeLangcode', + 'translationInitialize', + 'fieldDefinitions', + 'languages', + 'langcodeKey', + 'defaultLangcode', + 'defaultLangcodeKey', + 'revisionTranslationAffectedKey', + 'validated', + 'validationRunning', + 'validationRequired', + 'entityTypeId', + 'typedData', + 'cacheContexts', + 'cacheTags', + 'cacheMaxAge', + '_serviceIds', + '_entityStorages', + ]; foreach ($properties as $property) { // Modify each entity property on the clone and assert that the change is diff --git a/core/tests/Drupal/KernelTests/Core/Entity/ValidReferenceConstraintValidatorTest.php b/core/tests/Drupal/KernelTests/Core/Entity/ValidReferenceConstraintValidatorTest.php index 0b7cbbba..2ce0ae42 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/ValidReferenceConstraintValidatorTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/ValidReferenceConstraintValidatorTest.php @@ -5,6 +5,7 @@ use Drupal\Core\Field\BaseFieldDefinition; use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\entity_test\Entity\EntityTest; +use Drupal\entity_test\Entity\EntityTestConstraintViolation; use Drupal\field\Entity\FieldConfig; use Drupal\node\Entity\Node; use Drupal\node\NodeInterface; @@ -228,4 +229,74 @@ public function testPreExistingItemsValidation() { ]), $violations[1]->getMessage()); } + /** + * Tests the validation of new entities on an entity reference field. + * + * Additionally the test covers the recursive validation. The constraint + * validator "ValidReferenceConstraintValidator" will be executed for the + * parent entity and will trigger the validation of the referenced entities, + * which will lead to executing the same validator for each referenced entity. + * In order for the validation errors of the referenced entities to propagate + * it shouldn't happen that the same constraint validator object of the parent + * is used for the referenced entities. If the constraint validator object is + * reused during the recursive validation then its execution context will be + * exchanged when validating the referenced entities, which will not allow for + * their validation errors to propagate. + */ + public function testRecursiveValidation() { + $this->installEntitySchema('entity_test_constraint_violation'); + + // Add an entity reference field. + $this->createEntityReferenceField( + 'entity_test', + 'entity_test', + 'field_test', + 'Field test', + 'entity_test_constraint_violation', + 'default', + ['target_bundles' => ['entity_test_constraint_violation']], + FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED + ); + + // Create a new entity which will be referenced and should fail the + // validation. + $referenced_new_entity = EntityTestConstraintViolation::create([ + 'name' => 'Referenced Entity', + 'user_id' => ['target_id' => 0], + ]); + + $referencing_entity = EntityTest::create([ + 'field_test' => [ + ['entity' => $referenced_new_entity], + ], + 'user_id' => ['target_id' => 1], + ]); + + $violations = $referencing_entity->validate(); + $this->assertCount(3, $violations); + + $violation = $violations[0]; + $this->assertEquals('user_id.0.target_id', $violation->getPropertyPath()); + $this->assertEquals(t('The referenced entity (%type: %id) does not exist.', [ + '%type' => 'user', + '%id' => 1, + ]), $violation->getMessage()); + + $violation = $violations[1]; + $this->assertEquals('field_test.0.entity.name', $violation->getPropertyPath()); + $this->assertEquals(t('This entity (%type: %label) cannot be created. Reason(s): "%reason".', [ + '%type' => $referenced_new_entity->getEntityType()->getLabel() . ' ' . $referenced_new_entity->type->value, + '%label' => $referenced_new_entity->label(), + '%reason' => 'Widget constraint has failed.', + ]), $violation->getMessage()); + + $violation = $violations[2]; + $this->assertEquals('field_test.0.entity.test_field', $violation->getPropertyPath()); + $this->assertEquals(t('This entity (%type: %label) cannot be created. Reason(s): "%reason".', [ + '%type' => $referenced_new_entity->getEntityType()->getLabel() . ' ' . $referenced_new_entity->type->value, + '%label' => $referenced_new_entity->label(), + '%reason' => 'Widget constraint has failed.', + ]), $violation->getMessage()); + } + }