From 17f9026b28cf912f138397c553370743687a490a Mon Sep 17 00:00:00 2001 From: Lee Rowlands <lee.rowlands@previousnext.com.au> Date: Sat, 10 Aug 2019 07:22:22 +1000 Subject: [PATCH] Issue #2745797 by BR0kEN, piggito, harings_rob, heddn, biguzis, Jo Fitzgerald, renaudcuny, mikeryan, joachim, dawehner, quietone, AdamPS: Add option to content entity destinations for validation --- .../Exception/EntityValidationException.php | 87 +++++++++ .../MigrateValidatableEntityInterface.php | 36 ++++ .../migrate/destination/EntityContentBase.php | 41 ++++- .../MigrateEntityContentValidationTest.php | 169 ++++++++++++++++++ .../destination/EntityContentBaseTest.php | 2 + 5 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 core/modules/migrate/src/Exception/EntityValidationException.php create mode 100644 core/modules/migrate/src/Plugin/MigrateValidatableEntityInterface.php create mode 100644 core/modules/migrate/tests/src/Kernel/MigrateEntityContentValidationTest.php diff --git a/core/modules/migrate/src/Exception/EntityValidationException.php b/core/modules/migrate/src/Exception/EntityValidationException.php new file mode 100644 index 000000000000..7d0fb246ccf7 --- /dev/null +++ b/core/modules/migrate/src/Exception/EntityValidationException.php @@ -0,0 +1,87 @@ +<?php + +namespace Drupal\migrate\Exception; + +use Drupal\Core\Entity\EntityConstraintViolationListInterface; +use Drupal\Core\Entity\RevisionableInterface; +use Drupal\migrate\MigrateException; +use Symfony\Component\Validator\ConstraintViolationInterface; + +/** + * To throw when an entity generated during the import is not valid. + */ +class EntityValidationException extends MigrateException { + + /** + * The separator for combining multiple messages into a single string. + * + * Afterwards, the separator could be used to split a concatenated string + * onto multiple lines. + * + * @code + * explode(EntityValidationException::MESSAGES_SEPARATOR, $messages); + * @endcode + */ + const MESSAGES_SEPARATOR = '||'; + + /** + * The list of violations generated during the entity validation. + * + * @var \Drupal\Core\Entity\EntityConstraintViolationListInterface + */ + protected $violations; + + /** + * EntityValidationException constructor. + * + * @param \Drupal\Core\Entity\EntityConstraintViolationListInterface $violations + * The list of violations generated during the entity validation. + */ + public function __construct(EntityConstraintViolationListInterface $violations) { + $this->violations = $violations; + + $entity = $this->violations->getEntity(); + $locator = $entity->getEntityTypeId(); + + if ($entity_id = $entity->id()) { + $locator = sprintf('%s: %s', $locator, $entity_id); + + if ($entity instanceof RevisionableInterface && $revision_id = $entity->getRevisionId()) { + $locator .= sprintf(', revision: %s', $revision_id); + } + } + + // Example: "[user]: field_a=Violation 1., field_b=Violation 2.". + // Example: "[user: 1]: field_a=Violation 1., field_b=Violation 2.". + // Example: "[node: 19, revision: 12129]: field_a=Violation 1.". + parent::__construct(sprintf('[%s]: %s', $locator, implode(static::MESSAGES_SEPARATOR, $this->getViolationMessages()))); + } + + /** + * Returns the list of violation messages. + * + * @return string[] + * The list of violation messages. + */ + public function getViolationMessages() { + $messages = []; + + foreach ($this->violations as $violation) { + assert($violation instanceof ConstraintViolationInterface); + $messages[] = sprintf('%s=%s', $violation->getPropertyPath(), $violation->getMessage()); + } + + return $messages; + } + + /** + * Returns the list of violations generated during the entity validation. + * + * @return \Drupal\Core\Entity\EntityConstraintViolationListInterface + * The list of violations generated during the entity validation. + */ + public function getViolations() { + return $this->violations; + } + +} diff --git a/core/modules/migrate/src/Plugin/MigrateValidatableEntityInterface.php b/core/modules/migrate/src/Plugin/MigrateValidatableEntityInterface.php new file mode 100644 index 000000000000..baf0cdfcffda --- /dev/null +++ b/core/modules/migrate/src/Plugin/MigrateValidatableEntityInterface.php @@ -0,0 +1,36 @@ +<?php + +namespace Drupal\migrate\Plugin; + +use Drupal\Core\Entity\FieldableEntityInterface; + +/** + * To implement by a destination plugin that should provide entity validation. + * + * @ingroup migration + */ +interface MigrateValidatableEntityInterface { + + /** + * Returns a state of whether an entity needs to be validated before saving. + * + * @param \Drupal\Core\Entity\FieldableEntityInterface $entity + * The entity to check for required validation. + * + * @return bool + * A state of whether an entity needs to be validated. + */ + public function isEntityValidationRequired(FieldableEntityInterface $entity); + + /** + * Validates the entity. + * + * @param \Drupal\Core\Entity\FieldableEntityInterface $entity + * The entity to validate. + * + * @throws \Drupal\migrate\Exception\EntityValidationException + * When the validation didn't succeed. + */ + public function validateEntity(FieldableEntityInterface $entity); + +} diff --git a/core/modules/migrate/src/Plugin/migrate/destination/EntityContentBase.php b/core/modules/migrate/src/Plugin/migrate/destination/EntityContentBase.php index 24e552eddec8..3f8dae982f88 100644 --- a/core/modules/migrate/src/Plugin/migrate/destination/EntityContentBase.php +++ b/core/modules/migrate/src/Plugin/migrate/destination/EntityContentBase.php @@ -7,10 +7,13 @@ use Drupal\Core\Entity\EntityFieldManagerInterface; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityStorageInterface; +use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Field\FieldTypePluginManagerInterface; use Drupal\Core\TypedData\TranslatableInterface; use Drupal\Core\TypedData\TypedDataInterface; use Drupal\migrate\Audit\HighestIdInterface; +use Drupal\migrate\Exception\EntityValidationException; +use Drupal\migrate\Plugin\MigrateValidatableEntityInterface; use Drupal\migrate\Plugin\MigrationInterface; use Drupal\migrate\MigrateException; use Drupal\migrate\Plugin\MigrateIdMapInterface; @@ -26,6 +29,8 @@ * - overwrite_properties: (optional) A list of properties that will be * overwritten if an entity with the same ID already exists. Any properties * that are not listed will not be overwritten. + * - validate: (optional) Boolean, indicates whether an entity should be + * validated, defaults to FALSE. * * Example: * @@ -75,11 +80,14 @@ * overwrite_properties: * - title * - body + * # Run entity and fields validation before saving an entity. + * # @see \Drupal\Core\Entity\FieldableEntityInterface::validate() + * validate: true * @endcode * * @see \Drupal\migrate\Plugin\migrate\destination\EntityRevision */ -class EntityContentBase extends Entity implements HighestIdInterface { +class EntityContentBase extends Entity implements HighestIdInterface, MigrateValidatableEntityInterface { use DeprecatedServicePropertyTrait; /** @@ -146,6 +154,11 @@ public static function create(ContainerInterface $container, array $configuratio /** * {@inheritdoc} + * + * @throws \Drupal\migrate\MigrateException + * When an entity cannot be looked up. + * @throws \Drupal\migrate\Exception\EntityValidationException + * When an entity validation hasn't been passed. */ public function import(Row $row, array $old_destination_id_values = []) { $this->rollbackAction = MigrateIdMapInterface::ROLLBACK_DELETE; @@ -153,7 +166,10 @@ public function import(Row $row, array $old_destination_id_values = []) { if (!$entity) { throw new MigrateException('Unable to get entity'); } - + assert($entity instanceof ContentEntityInterface); + if ($this->isEntityValidationRequired($entity)) { + $this->validateEntity($entity); + } $ids = $this->save($entity, $old_destination_id_values); if ($this->isTranslationDestination()) { $ids[] = $entity->language()->getId(); @@ -161,6 +177,27 @@ public function import(Row $row, array $old_destination_id_values = []) { return $ids; } + /** + * {@inheritdoc} + */ + public function isEntityValidationRequired(FieldableEntityInterface $entity) { + // Prioritize the entity method over migration config because it won't be + // possible to save that entity unvalidated. + /* @see \Drupal\Core\Entity\ContentEntityBase::preSave() */ + return $entity->isValidationRequired() || !empty($this->configuration['validate']); + } + + /** + * {@inheritdoc} + */ + public function validateEntity(FieldableEntityInterface $entity) { + $violations = $entity->validate(); + + if (count($violations) > 0) { + throw new EntityValidationException($violations); + } + } + /** * Saves the entity. * diff --git a/core/modules/migrate/tests/src/Kernel/MigrateEntityContentValidationTest.php b/core/modules/migrate/tests/src/Kernel/MigrateEntityContentValidationTest.php new file mode 100644 index 000000000000..f5632e24df86 --- /dev/null +++ b/core/modules/migrate/tests/src/Kernel/MigrateEntityContentValidationTest.php @@ -0,0 +1,169 @@ +<?php + +namespace Drupal\Tests\migrate\Kernel; + +use Drupal\KernelTests\KernelTestBase; +use Drupal\migrate\Event\MigrateEvents; +use Drupal\migrate\Event\MigrateIdMapMessageEvent; +use Drupal\migrate\MigrateExecutable; +use Drupal\user\Plugin\Validation\Constraint\UserNameConstraint; + +/** + * Tests validation of an entity during migration. + * + * @group migrate + */ +class MigrateEntityContentValidationTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['migrate', 'system', 'user', 'entity_test']; + + /** + * Messages accumulated during the migration run. + * + * @var string[] + */ + protected $messages = []; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + $this->installConfig(['system', 'user']); + $this->installEntitySchema('user'); + $this->installEntitySchema('entity_test'); + + $this->container + ->get('event_dispatcher') + ->addListener(MigrateEvents::IDMAP_MESSAGE, [$this, 'mapMessageRecorder']); + } + + /** + * Tests an import with invalid data and checks error messages. + */ + public function test1() { + // Make sure that a user with uid 2 exists. + $this->container + ->get('entity_type.manager') + ->getStorage('user') + ->create([ + 'uid' => 2, + 'name' => $this->randomMachineName(), + 'status' => 1, + ]) + ->save(); + + $this->runImport([ + 'source' => [ + 'plugin' => 'embedded_data', + 'data_rows' => [ + [ + 'id' => '1', + 'name' => $this->randomString(256), + 'user_id' => '1', + ], + [ + 'id' => '2', + 'name' => $this->randomString(32), + 'user_id' => '1', + ], + [ + 'id' => '3', + 'name' => $this->randomString(32), + 'user_id' => '2', + ], + ], + 'ids' => [ + 'id' => ['type' => 'integer'], + ], + ], + 'process' => [ + 'id' => 'id', + 'name' => 'name', + 'user_id' => 'user_id', + ], + 'destination' => [ + 'plugin' => 'entity:entity_test', + 'validate' => TRUE, + ], + ]); + + $this->assertSame('1: [entity_test: 1]: name.0.value=<em class="placeholder">Name</em>: may not be longer than 32 characters.||user_id.0.target_id=The referenced entity (<em class="placeholder">user</em>: <em class="placeholder">1</em>) does not exist.', $this->messages[0], 'First message should have 2 validation errors.'); + $this->assertSame('2: [entity_test: 2]: user_id.0.target_id=The referenced entity (<em class="placeholder">user</em>: <em class="placeholder">1</em>) does not exist.', $this->messages[1], 'Second message should have 1 validation error.'); + $this->assertArrayNotHasKey(2, $this->messages, 'Third message should not exist.'); + } + + /** + * Tests an import with invalid data and checks error messages. + */ + public function test2() { + $long_username = $this->randomString(61); + $username_constraint = new UserNameConstraint(); + + $this->runImport([ + 'source' => [ + 'plugin' => 'embedded_data', + 'data_rows' => [ + [ + 'id' => 1, + 'name' => $long_username, + ], + [ + 'id' => 2, + 'name' => $this->randomString(32), + ], + [ + 'id' => 3, + 'name' => $this->randomString(32), + ], + ], + 'ids' => [ + 'id' => ['type' => 'integer'], + ], + ], + 'process' => [ + 'name' => 'name', + ], + 'destination' => [ + 'plugin' => 'entity:user', + 'validate' => TRUE, + ], + ]); + + $this->assertSame(sprintf('1: [user]: name=%s||name=%s||mail=Email field is required.', $username_constraint->illegalMessage, t($username_constraint->tooLongMessage, ['%name' => $long_username, '%max' => 60])), $this->messages[0], 'First message should have 3 validation errors.'); + $this->assertSame(sprintf('2: [user]: name=%s||mail=Email field is required.', $username_constraint->illegalMessage), $this->messages[1], 'Second message should have 2 validation errors.'); + $this->assertSame(sprintf('3: [user]: name=%s||mail=Email field is required.', $username_constraint->illegalMessage), $this->messages[2], 'Third message should have 2 validation errors.'); + $this->assertArrayNotHasKey(3, $this->messages, 'Fourth message should not exist.'); + } + + /** + * Reacts to map message event. + * + * @param \Drupal\Migrate\Event\MigrateIdMapMessageEvent $event + * The migration event. + */ + public function mapMessageRecorder(MigrateIdMapMessageEvent $event) { + $this->messages[] = implode(',', $event->getSourceIdValues()) . ': ' . $event->getMessage(); + } + + /** + * Runs an import of a migration. + * + * @param array $definition + * The migration definition. + * + * @throws \Exception + * @throws \Drupal\migrate\MigrateException + */ + protected function runImport(array $definition) { + // Reset the list of messages from a previous migration. + $this->messages = []; + + (new MigrateExecutable($this->container->get('plugin.manager.migration')->createStubMigration($definition)))->import(); + } + +} diff --git a/core/modules/migrate/tests/src/Unit/Plugin/migrate/destination/EntityContentBaseTest.php b/core/modules/migrate/tests/src/Unit/Plugin/migrate/destination/EntityContentBaseTest.php index 9b1ede8dfe4e..3a4d12ef4e3d 100644 --- a/core/modules/migrate/tests/src/Unit/Plugin/migrate/destination/EntityContentBaseTest.php +++ b/core/modules/migrate/tests/src/Unit/Plugin/migrate/destination/EntityContentBaseTest.php @@ -36,6 +36,8 @@ public function testImport() { $this->entityFieldManager->reveal(), $this->prophesize(FieldTypePluginManagerInterface::class)->reveal()); $entity = $this->prophesize(ContentEntityInterface::class); + $entity->isValidationRequired() + ->shouldBeCalledTimes(1); // Assert that save is called. $entity->save() ->shouldBeCalledTimes(1); -- GitLab