diff --git a/core/modules/migrate/src/Event/MigrateEvents.php b/core/modules/migrate/src/Event/MigrateEvents.php index 2370c763844..a6cde25030f 100644 --- a/core/modules/migrate/src/Event/MigrateEvents.php +++ b/core/modules/migrate/src/Event/MigrateEvents.php @@ -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'; + } diff --git a/core/modules/migrate/src/Event/MigrateRollbackEvent.php b/core/modules/migrate/src/Event/MigrateRollbackEvent.php new file mode 100644 index 00000000000..1a6ac314b71 --- /dev/null +++ b/core/modules/migrate/src/Event/MigrateRollbackEvent.php @@ -0,0 +1,45 @@ +migration = $migration; + } + + /** + * Gets the migration entity. + * + * @return \Drupal\migrate\Entity\MigrationInterface + * The migration entity involved. + */ + public function getMigration() { + return $this->migration; + } + +} diff --git a/core/modules/migrate/src/Event/MigrateRowDeleteEvent.php b/core/modules/migrate/src/Event/MigrateRowDeleteEvent.php new file mode 100644 index 00000000000..7d3766af5b1 --- /dev/null +++ b/core/modules/migrate/src/Event/MigrateRowDeleteEvent.php @@ -0,0 +1,65 @@ +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; + } + +} diff --git a/core/modules/migrate/src/MigrateExecutable.php b/core/modules/migrate/src/MigrateExecutable.php index 8a42bc9036b..b037c790cf1 100644 --- a/core/modules/migrate/src/MigrateExecutable.php +++ b/core/modules/migrate/src/MigrateExecutable.php @@ -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} */ diff --git a/core/modules/migrate/src/MigrateExecutableInterface.php b/core/modules/migrate/src/MigrateExecutableInterface.php index 3edf6200559..71fa0176946 100644 --- a/core/modules/migrate/src/MigrateExecutableInterface.php +++ b/core/modules/migrate/src/MigrateExecutableInterface.php @@ -16,6 +16,11 @@ interface MigrateExecutableInterface { */ public function import(); + /** + * Performs a rollback operation - remove previously-imported items. + */ + public function rollback(); + /** * Processes a row. * diff --git a/core/modules/migrate/src/Plugin/MigrateDestinationInterface.php b/core/modules/migrate/src/Plugin/MigrateDestinationInterface.php index efa873c958d..e19e24c5325 100644 --- a/core/modules/migrate/src/Plugin/MigrateDestinationInterface.php +++ b/core/modules/migrate/src/Plugin/MigrateDestinationInterface.php @@ -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(); } diff --git a/core/modules/migrate/src/Plugin/MigrateIdMapInterface.php b/core/modules/migrate/src/Plugin/MigrateIdMapInterface.php index d6f17df3442..144c8999f8d 100644 --- a/core/modules/migrate/src/Plugin/MigrateIdMapInterface.php +++ b/core/modules/migrate/src/Plugin/MigrateIdMapInterface.php @@ -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. * diff --git a/core/modules/migrate/src/Plugin/migrate/destination/Config.php b/core/modules/migrate/src/Plugin/migrate/destination/Config.php index ac05db42296..7cc737e0d74 100644 --- a/core/modules/migrate/src/Plugin/migrate/destination/Config.php +++ b/core/modules/migrate/src/Plugin/migrate/destination/Config.php @@ -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} */ diff --git a/core/modules/migrate/src/Plugin/migrate/destination/DestinationBase.php b/core/modules/migrate/src/Plugin/migrate/destination/DestinationBase.php index 663a4290c3a..4f1d596dc99 100644 --- a/core/modules/migrate/src/Plugin/migrate/destination/DestinationBase.php +++ b/core/modules/migrate/src/Plugin/migrate/destination/DestinationBase.php @@ -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; + } } diff --git a/core/modules/migrate/src/Plugin/migrate/destination/Entity.php b/core/modules/migrate/src/Plugin/migrate/destination/Entity.php index c3cc71b5bc4..64123d6d448 100644 --- a/core/modules/migrate/src/Plugin/migrate/destination/Entity.php +++ b/core/modules/migrate/src/Plugin/migrate/destination/Entity.php @@ -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} */ diff --git a/core/modules/migrate/src/Plugin/migrate/id_map/Sql.php b/core/modules/migrate/src/Plugin/migrate/id_map/Sql.php index 12bb419fe85..ff832ef52b9 100644 --- a/core/modules/migrate/src/Plugin/migrate/id_map/Sql.php +++ b/core/modules/migrate/src/Plugin/migrate/id_map/Sql.php @@ -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(). * diff --git a/core/modules/migrate/src/Tests/MigrateRollbackTest.php b/core/modules/migrate/src/Tests/MigrateRollbackTest.php new file mode 100644 index 00000000000..ce6eb0051e4 --- /dev/null +++ b/core/modules/migrate/src/Tests/MigrateRollbackTest.php @@ -0,0 +1,167 @@ +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()); + } + +}