Unverified Commit 17f9026b authored by larowlan's avatar larowlan

Issue #2745797 by BR0kEN, piggito, harings_rob, heddn, biguzis, Jo Fitzgerald,...

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
parent d6bc6146
<?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;
}
}
<?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);
}
......@@ -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.
*
......
<?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();
}
}
......@@ -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);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment