Issue #2361093 by mikeryan, dawehner, Devin Carlson, benjy, phenaproxima: Add a rollback functionality to migrate

8.0.x
webchick 2015-09-28 10:04:29 -07:00
parent 6f8ba53505
commit 07c8d58cbe
12 changed files with 464 additions and 30 deletions

View File

@ -15,6 +15,8 @@ namespace Drupal\migrate\Event;
* @see \Drupal\migrate\Event\MigrateImportEvent
* @see \Drupal\migrate\Event\MigratePreRowSaveEvent
* @see \Drupal\migrate\Event\MigratePostRowSaveEvent
* @see \Drupal\migrate\Event\MigrateRollbackEvent
* @see \Drupal\migrate\Event\MigrateRowDeleteEvent
*/
final class MigrateEvents {
@ -108,4 +110,64 @@ final class MigrateEvents {
*/
const POST_ROW_SAVE = 'migrate.post_row_save';
/**
* Name of the event fired when beginning a migration rollback operation.
*
* This event allows modules to perform an action whenever a migration
* rollback operation is about to begin. The event listener method receives a
* \Drupal\migrate\Event\MigrateRollbackEvent instance.
*
* @Event
*
* @see \Drupal\migrate\Event\MigrateRollbackEvent
*
* @var string
*/
const PRE_ROLLBACK = 'migrate.pre_rollback';
/**
* Name of the event fired when finishing a migration rollback operation.
*
* This event allows modules to perform an action whenever a migration
* rollback operation is completing. The event listener method receives a
* \Drupal\migrate\Event\MigrateRollbackEvent instance.
*
* @Event
*
* @see \Drupal\migrate\Event\MigrateRollbackEvent
*
* @var string
*/
const POST_ROLLBACK = 'migrate.post_rollback';
/**
* Name of the event fired when about to delete a single item.
*
* This event allows modules to perform an action whenever a specific item
* is about to be deleted by the destination plugin. The event listener method
* receives a \Drupal\migrate\Event\MigrateRowDeleteEvent instance.
*
* @Event
*
* @see \Drupal\migrate\Event\MigrateRowDeleteEvent
*
* @var string
*/
const PRE_ROW_DELETE = 'migrate.pre_row_delete';
/**
* Name of the event fired just after a single item has been deleted.
*
* This event allows modules to perform an action whenever a specific item
* has been deleted by the destination plugin. The event listener method
* receives a \Drupal\migrate\Event\MigrateRowDeleteEvent instance.
*
* @Event
*
* @see \Drupal\migrate\Event\MigrateRowDeleteEvent
*
* @var string
*/
const POST_ROW_DELETE = 'migrate.post_row_delete';
}

View File

@ -0,0 +1,45 @@
<?php
/**
* @file
* Contains \Drupal\migrate\Event\MigrateRollbackEvent.
*/
namespace Drupal\migrate\Event;
use Drupal\migrate\Entity\MigrationInterface;
use Symfony\Component\EventDispatcher\Event;
/**
* Wraps a pre- or post-rollback event for event listeners.
*/
class MigrateRollbackEvent extends Event {
/**
* Migration entity.
*
* @var \Drupal\migrate\Entity\MigrationInterface
*/
protected $migration;
/**
* Constructs an rollback event object.
*
* @param \Drupal\migrate\Entity\MigrationInterface $migration
* Migration entity.
*/
public function __construct(MigrationInterface $migration) {
$this->migration = $migration;
}
/**
* Gets the migration entity.
*
* @return \Drupal\migrate\Entity\MigrationInterface
* The migration entity involved.
*/
public function getMigration() {
return $this->migration;
}
}

View File

@ -0,0 +1,65 @@
<?php
/**
* @file
* Contains \Drupal\migrate\Event\MigrateRowDeleteEvent.
*/
namespace Drupal\migrate\Event;
use Drupal\migrate\Entity\MigrationInterface;
use Symfony\Component\EventDispatcher\Event;
/**
* Wraps a row deletion event for event listeners.
*/
class MigrateRowDeleteEvent extends Event {
/**
* Migration entity.
*
* @var \Drupal\migrate\Entity\MigrationInterface
*/
protected $migration;
/**
* Values representing the destination ID.
*
* @var array
*/
protected $destinationIdValues;
/**
* Constructs a row deletion event object.
*
* @param \Drupal\migrate\Entity\MigrationInterface $migration
* Migration entity.
* @param array $destination_id_values
* Values represent the destination ID.
*/
public function __construct(MigrationInterface $migration, $destination_id_values) {
$this->migration = $migration;
$this->destinationIdValues = $destination_id_values;
}
/**
* Gets the migration entity.
*
* @return \Drupal\migrate\Entity\MigrationInterface
* The migration being rolled back.
*/
public function getMigration() {
return $this->migration;
}
/**
* Gets the destination ID values.
*
* @return array
* The destination ID as an array.
*/
public function getDestinationIdValues() {
return $this->destinationIdValues;
}
}

View File

@ -14,6 +14,8 @@ use Drupal\migrate\Event\MigrateEvents;
use Drupal\migrate\Event\MigrateImportEvent;
use Drupal\migrate\Event\MigratePostRowSaveEvent;
use Drupal\migrate\Event\MigratePreRowSaveEvent;
use Drupal\migrate\Event\MigrateRollbackEvent;
use Drupal\migrate\Event\MigrateRowDeleteEvent;
use Drupal\migrate\Exception\RequirementsException;
use Drupal\migrate\Plugin\MigrateIdMapInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@ -76,15 +78,6 @@ class MigrateExecutable implements MigrateExecutableInterface {
*/
protected $counts = array();
/**
* The maximum number of items to pass in a single call during a rollback.
*
* For use in bulkRollback(). Can be overridden in derived class constructor.
*
* @var int
*/
protected $rollbackBatchSize = 50;
/**
* The object currently being constructed.
*
@ -312,6 +305,64 @@ class MigrateExecutable implements MigrateExecutableInterface {
return $return;
}
/**
* {@inheritdoc}
*/
public function rollback() {
// Only begin the rollback operation if the migration is currently idle.
if ($this->migration->getStatus() !== MigrationInterface::STATUS_IDLE) {
$this->message->display($this->t('Migration @id is busy with another operation: @status', ['@id' => $this->migration->id(), '@status' => $this->t($this->migration->getStatusLabel())]), 'error');
return MigrationInterface::RESULT_FAILED;
}
// Announce that rollback is about to happen.
$this->getEventDispatcher()->dispatch(MigrateEvents::PRE_ROLLBACK, new MigrateRollbackEvent($this->migration));
// Optimistically assume things are going to work out; if not, $return will be
// updated to some other status.
$return = MigrationInterface::RESULT_COMPLETED;
$this->migration->setStatus(MigrationInterface::STATUS_ROLLING_BACK);
$id_map = $this->migration->getIdMap();
$destination = $this->migration->getDestinationPlugin();
// Loop through each row in the map, and try to roll it back.
foreach ($id_map as $serialized_key => $map_row) {
$destination_key = $id_map->currentDestination();
if ($destination_key) {
$this->getEventDispatcher()
->dispatch(MigrateEvents::PRE_ROW_DELETE, new MigrateRowDeleteEvent($this->migration, $destination_key));
$destination->rollback($destination_key);
$this->getEventDispatcher()
->dispatch(MigrateEvents::POST_ROW_DELETE, new MigrateRowDeleteEvent($this->migration, $destination_key));
// We're now done with this row, so remove it from the map.
$id_map->delete(unserialize($serialized_key));
}
// Check for memory exhaustion.
if (($return = $this->checkStatus()) != MigrationInterface::RESULT_COMPLETED) {
break;
}
// If anyone has requested we stop, return the requested result.
if ($this->migration->getStatus() == MigrationInterface::STATUS_STOPPING) {
$return = $this->migration->getMigrationResult();
break;
}
}
// If rollback completed successfully, reset the high water mark.
if ($return == MigrationInterface::RESULT_COMPLETED) {
$this->migration->saveHighWater(NULL);
}
// Notify modules that rollback attempt was complete.
$this->migration->setMigrationResult($return);
$this->getEventDispatcher()->dispatch(MigrateEvents::POST_ROLLBACK, new MigrateRollbackEvent($this->migration));
$this->migration->setStatus(MigrationInterface::STATUS_IDLE);
return $return;
}
/**
* {@inheritdoc}
*/

View File

@ -16,6 +16,11 @@ interface MigrateExecutableInterface {
*/
public function import();
/**
* Performs a rollback operation - remove previously-imported items.
*/
public function rollback();
/**
* Processes a row.
*

View File

@ -59,7 +59,7 @@ interface MigrateDestinationInterface extends PluginInspectionInterface {
* Import the row.
*
* Derived classes must implement import(), to construct one new object
* (pre-populated) using ID mappings in the Migration).
* (pre-populated) using ID mappings in the Migration.
*
* @param \Drupal\migrate\Row $row
* The row object.
@ -72,11 +72,15 @@ interface MigrateDestinationInterface extends PluginInspectionInterface {
public function import(Row $row, array $old_destination_id_values = array());
/**
* Delete the specified IDs from the target Drupal.
* Delete the specified destination object from the target Drupal.
*
* @param array $destination_identifiers
* The destination ids to delete.
* @param array $destination_identifier
* The ID of the destination object to delete.
*/
public function rollbackMultiple(array $destination_identifiers);
public function rollback(array $destination_identifier);
/**
* @return bool
*/
public function supportsRollback();
}

View File

@ -210,7 +210,7 @@ interface MigrateIdMapInterface extends \Iterator, PluginInspectionInterface {
public function lookupSourceID(array $destination_id_values);
/**
* Looks up the destination identifier.
* Looks up the destination identifier corresponding to a source key.
*
* Given a (possibly multi-field) source identifier value, return the
* (possibly multi-field) destination identifier value it is mapped to.
@ -223,6 +223,14 @@ interface MigrateIdMapInterface extends \Iterator, PluginInspectionInterface {
*/
public function lookupDestinationId(array $source_id_values);
/**
* Looks up the destination identifier currently being iterated.
*
* @return array
* The destination identifier values of the record, or NULL on failure.
*/
public function currentDestination();
/**
* Removes any persistent storage used by this map.
*

View File

@ -13,10 +13,8 @@ use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\DependencyTrait;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate\Entity\MigrationInterface;
use Drupal\migrate\MigrateException;
use Drupal\migrate\Row;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Config\Config as ConfigObject;
/**
* Persist data to the config system.
@ -83,18 +81,6 @@ class Config extends DestinationBase implements ContainerFactoryPluginInterface,
return TRUE;
}
/**
* Throw an exception because config can not be rolled back.
*
* @param array $destination_keys
* The array of destination ids to roll back.
*
* @throws \Drupal\migrate\MigrateException
*/
public function rollbackMultiple(array $destination_keys) {
throw new MigrateException('Configuration can not be rolled back');
}
/**
* {@inheritdoc}
*/

View File

@ -26,6 +26,13 @@ use Drupal\migrate\Plugin\RequirementsInterface;
*/
abstract class DestinationBase extends PluginBase implements MigrateDestinationInterface, RequirementsInterface {
/**
* Indicates whether the destination can be rolled back.
*
* @var bool
*/
protected $supportsRollback = FALSE;
/**
* The migration.
*
@ -62,8 +69,14 @@ abstract class DestinationBase extends PluginBase implements MigrateDestinationI
/**
* {@inheritdoc}
*/
public function rollbackMultiple(array $destination_identifiers) {
public function rollback(array $destination_identifier) {
// By default we do nothing.
}
/**
* {@inheritdoc}
*/
public function supportsRollback() {
return $this->supportsRollback;
}
}

View File

@ -58,6 +58,7 @@ abstract class Entity extends DestinationBase implements ContainerFactoryPluginI
parent::__construct($configuration, $plugin_id, $plugin_definition, $migration);
$this->storage = $storage;
$this->bundles = $bundles;
$this->supportsRollback = TRUE;
}
/**
@ -163,6 +164,17 @@ abstract class Entity extends DestinationBase implements ContainerFactoryPluginI
return $this->storage->getEntityType()->getKey($key);
}
/**
* {@inheritdoc}
*/
public function rollback(array $destination_identifier) {
// Delete the specified entity from Drupal if it exists.
$entity = $this->storage->load(reset($destination_identifier));
if ($entity) {
$entity->delete();
}
}
/**
* {@inheritdoc}
*/

View File

@ -805,6 +805,22 @@ class Sql extends PluginBase implements MigrateIdMapInterface, ContainerFactoryP
return serialize($this->currentKey);
}
/**
* @inheritdoc
*/
public function currentDestination() {
if ($this->valid()) {
$result = array();
foreach ($this->destinationIdFields() as $field_name) {
$result[$field_name] = $this->currentRow[$field_name];
}
return $result;
}
else {
return NULL;
}
}
/**
* Implementation of Iterator::next().
*

View File

@ -0,0 +1,167 @@
<?php
/**
* @file
* Contains \Drupal\migrate\Tests\MigrateRollbackTest.
*/
namespace Drupal\migrate\Tests;
use Drupal\migrate\Entity\Migration;
use Drupal\migrate\MigrateExecutable;
use Drupal\taxonomy\Entity\Term;
use Drupal\taxonomy\Entity\Vocabulary;
/**
* Tests rolling back of imports.
*
* @group migrate
*/
class MigrateRollbackTest extends MigrateTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['field', 'taxonomy', 'text'];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installEntitySchema('taxonomy_vocabulary');
$this->installEntitySchema('taxonomy_term');
$this->installConfig(['taxonomy']);
}
/**
* Tests rolling back configuration and content entities.
*/
public function testRollback() {
// We use vocabularies to demonstrate importing and rolling back
// configuration entities.
$vocabulary_data_rows = [
['id' => '1', 'name' => 'categories', 'weight' => '2'],
['id' => '2', 'name' => 'tags', 'weight' => '1'],
];
$ids = ['id' => ['type' => 'integer']];
$config = [
'id' => 'vocabularies',
'migration_tags' => ['Import and rollback test'],
'source' => [
'plugin' => 'embedded_data',
'data_rows' => $vocabulary_data_rows,
'ids' => $ids,
],
'process' => [
'vid' => 'id',
'name' => 'name',
'weight' => 'weight',
],
'destination' => ['plugin' => 'entity:taxonomy_vocabulary'],
];
$vocabulary_migration = Migration::create($config);
$vocabulary_id_map = $vocabulary_migration->getIdMap();
$this->assertTrue($vocabulary_migration->getDestinationPlugin()->supportsRollback());
// Import and validate vocabulary config entities were created.
$vocabulary_executable = new MigrateExecutable($vocabulary_migration, $this);
$vocabulary_executable->import();
foreach ($vocabulary_data_rows as $row) {
/** @var Vocabulary $vocabulary */
$vocabulary = Vocabulary::load($row['id']);
$this->assertTrue($vocabulary);
$map_row = $vocabulary_id_map->getRowBySource([$row['id']]);
$this->assertNotNull($map_row['destid1']);
}
// We use taxonomy terms to demonstrate importing and rolling back
// content entities.
$term_data_rows = [
['id' => '1', 'vocab' => '1', 'name' => 'music'],
['id' => '2', 'vocab' => '2', 'name' => 'Bach'],
['id' => '3', 'vocab' => '2', 'name' => 'Beethoven'],
];
$ids = ['id' => ['type' => 'integer']];
$config = [
'id' => 'terms',
'migration_tags' => ['Import and rollback test'],
'source' => [
'plugin' => 'embedded_data',
'data_rows' => $term_data_rows,
'ids' => $ids,
],
'process' => [
'tid' => 'id',
'vid' => 'vocab',
'name' => 'name',
],
'destination' => ['plugin' => 'entity:taxonomy_term'],
'migration_dependencies' => ['required' => ['vocabularies']],
];
$term_migration = Migration::create($config);
$term_id_map = $term_migration->getIdMap();
$this->assertTrue($term_migration->getDestinationPlugin()->supportsRollback());
// Import and validate term entities were created.
$term_executable = new MigrateExecutable($term_migration, $this);
$term_executable->import();
foreach ($term_data_rows as $row) {
/** @var Term $term */
$term = Term::load($row['id']);
$this->assertTrue($term);
$map_row = $term_id_map->getRowBySource([$row['id']]);
$this->assertNotNull($map_row['destid1']);
}
// Rollback and verify the entities are gone.
$term_executable->rollback();
foreach ($term_data_rows as $row) {
$term = Term::load($row['id']);
$this->assertNull($term);
$map_row = $term_id_map->getRowBySource([$row['id']]);
$this->assertFalse($map_row);
}
$vocabulary_executable->rollback();
foreach ($vocabulary_data_rows as $row) {
$term = Vocabulary::load($row['id']);
$this->assertNull($term);
$map_row = $vocabulary_id_map->getRowBySource([$row['id']]);
$this->assertFalse($map_row);
}
// Test that simple configuration is not rollbackable.
$term_setting_rows = [
['id' => 1, 'override_selector' => '0', 'terms_per_page_admin' => '10'],
];
$ids = ['id' => ['type' => 'integer']];
$config = [
'id' => 'taxonomy_settings',
'migration_tags' => ['Import and rollback test'],
'source' => [
'plugin' => 'embedded_data',
'data_rows' => $term_setting_rows,
'ids' => $ids,
],
'process' => [
'override_selector' => 'override_selector',
'terms_per_page_admin' => 'terms_per_page_admin',
],
'destination' => [
'plugin' => 'config',
'config_name' => 'taxonomy.settings',
],
'migration_dependencies' => ['required' => ['vocabularies']],
];
$settings_migration = Migration::create($config);
$this->assertFalse($settings_migration->getDestinationPlugin()->supportsRollback());
}
}