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