Commit c136a851 authored by xjm's avatar xjm

Issue #2281691 by xjm, mikeryan, Gábor Hojtsy, alexpott, tim.plunkett,...

Issue #2281691 by xjm, mikeryan, Gábor Hojtsy, alexpott, tim.plunkett, webchick, abhishek-anand, quietone, yoroy, stevepurkiss, benjy, Bojhan, Ryan Weal, aleksip, anavarre, hussainweb, er.pushpinderrana, cilefen, Luukyb, Jo Fitzgerald: User interface for migration-based upgrades
parent 50ca1107
......@@ -101,6 +101,7 @@
"drupal/menu_ui": "self.version",
"drupal/migrate": "self.version",
"drupal/migrate_drupal": "self.version",
"drupal/migrate_drupal_ui": "self.version",
"drupal/node": "self.version",
"drupal/options": "self.version",
"drupal/page_cache": "self.version",
......
<?php
/**
* @file
* Contains \Drupal\migrate_drupal\MigrationCreationTrait.
*/
namespace Drupal\migrate_drupal;
use Drupal\Core\Database\Connection;
use Drupal\Core\Database\Database;
use Drupal\migrate\Entity\Migration;
use Drupal\migrate\Exception\RequirementsException;
use Drupal\migrate\Plugin\RequirementsInterface;
use Drupal\Component\Plugin\Exception\PluginNotFoundException;
/**
* Creates the appropriate migrations for a given source Drupal database.
*/
trait MigrationCreationTrait {
/**
* Gets the database connection for the source Drupal database.
*
* @param array $database
* Database array representing the source Drupal database.
*
* @return \Drupal\Core\Database\Connection
* The database connection for the source Drupal database.
*/
protected function getConnection(array $database) {
// Set up the connection.
Database::addConnectionInfo('upgrade', 'default', $database);
$connection = Database::getConnection('default', 'upgrade');
return $connection;
}
/**
* Gets the system data from the system table of the source Drupal database.
*
* @param array $database
* Database array representing the source Drupal database.
*
* @return array
* The system data from the system table of the source Drupal database.
*/
protected function getSystemData(array $database) {
$connection = $this->getConnection($database);
$system_data = [];
try {
$results = $connection->select('system', 's', [
'fetch' => \PDO::FETCH_ASSOC,
])
->fields('s')
->execute();
foreach ($results as $result) {
$system_data[$result['type']][$result['name']] = $result;
}
}
catch (\Exception $e) {
// The table might not exist for example in tests.
}
return $system_data;
}
/**
* Sets up the relevant migrations for import from a database connection.
*
* @param array $database
* Database array representing the source Drupal database.
* @param string $source_base_path
* (Optional) Address of the source Drupal site (e.g., http://example.com/).
*
* @return array
* An array of the migration templates (parsed YAML config arrays) that were
* tagged for the identified source Drupal version. The templates are
* populated with database state key and file source base path information
* for execution. The array is keyed by migration IDs.
*
* @throws \Exception
*/
protected function getMigrationTemplates(array $database, $source_base_path = '') {
// Set up the connection.
$connection = $this->getConnection($database);
if (!$drupal_version = $this->getLegacyDrupalVersion($connection)) {
throw new \Exception('Source database does not contain a recognizable Drupal version.');
}
$database_state['key'] = 'upgrade';
$database_state['database'] = $database;
$database_state_key = 'migrate_drupal_' . $drupal_version;
\Drupal::state()->set($database_state_key, $database_state);
$version_tag = 'Drupal ' . $drupal_version;
$template_storage = \Drupal::service('migrate.template_storage');
$migration_templates = $template_storage->findTemplatesByTag($version_tag);
foreach ($migration_templates as $id => $template) {
$migration_templates[$id]['source']['database_state_key'] = $database_state_key;
// Configure file migrations so they can find the files.
if ($template['destination']['plugin'] == 'entity:file') {
if ($source_base_path) {
// Make sure we have a single trailing slash.
$source_base_path = rtrim($source_base_path, '/') . '/';
$migration_templates[$id]['destination']['source_base_path'] = $source_base_path;
}
}
}
return $migration_templates;
}
/**
* Gets the migrations for import.
*
* Uses the migration template connection to ensure that only the relevant
* migrations are returned.
*
* @param array $migration_templates
* Migration templates (parsed YAML config arrays), keyed by the ID.
*
* @return \Drupal\migrate\Entity\MigrationInterface[]
* The migrations for import.
*/
protected function getMigrations(array $migration_templates) {
// Let the builder service create our migration configuration entities from
// the templates, expanding them to multiple entities where necessary.
/** @var \Drupal\migrate\MigrationBuilder $builder */
$builder = \Drupal::service('migrate.migration_builder');
$initial_migrations = $builder->createMigrations($migration_templates);
$migrations = [];
foreach ($initial_migrations as $migration) {
try {
// Any plugin that has specific requirements to check will implement
// RequirementsInterface.
$source_plugin = $migration->getSourcePlugin();
if ($source_plugin instanceof RequirementsInterface) {
$source_plugin->checkRequirements();
}
$destination_plugin = $migration->getDestinationPlugin();
if ($destination_plugin instanceof RequirementsInterface) {
$destination_plugin->checkRequirements();
}
$migrations[] = $migration;
}
// Migrations which are not applicable given the source and destination
// site configurations (e.g., what modules are enabled) will be silently
// ignored.
catch (RequirementsException $e) {
}
catch (PluginNotFoundException $e) {
}
}
return $migrations;
}
/**
* Saves the migrations for import from the provided template connection.
*
* @param array $migration_templates
* Migration template.
*
* @return array
* The migration IDs sorted in dependency order.
*/
protected function createMigrations(array $migration_templates) {
$migration_ids = [];
$migrations = $this->getMigrations($migration_templates);
foreach ($migrations as $migration) {
// Don't try to resave migrations that already exist.
if (!Migration::load($migration->id())) {
$migration->save();
}
$migration_ids[] = $migration->id();
}
// loadMultiple will sort the migrations in dependency order.
return array_keys(Migration::loadMultiple($migration_ids));
}
/**
* Determines what version of Drupal the source database contains.
*
* @param \Drupal\Core\Database\Connection $connection
* The database connection object.
*
* @return int|FALSE
* An integer representing the major branch of Drupal core (e.g. '6' for
* Drupal 6.x), or FALSE if no valid version is matched.
*/
protected function getLegacyDrupalVersion(Connection $connection) {
// Don't assume because a table of that name exists, that it has the columns
// we're querying. Catch exceptions and report that the source database is
// not Drupal.
// Drupal 5/6/7 can be detected by the schema_version in the system table.
if ($connection->schema()->tableExists('system')) {
try {
$version_string = $connection
->query('SELECT schema_version FROM {system} WHERE name = :module', [':module' => 'system'])
->fetchField();
if ($version_string && $version_string[0] == '1') {
if ((int) $version_string >= 1000) {
$version_string = '5';
}
else {
$version_string = FALSE;
}
}
}
catch (\PDOException $e) {
$version_string = FALSE;
}
}
// For Drupal 8 (and we're predicting beyond) the schema version is in the
// key_value store.
elseif ($connection->schema()->tableExists('key_value')) {
$result = $connection
->query("SELECT value FROM {key_value} WHERE collection = :system_schema and name = :module", [':system_schema' => 'system.schema', ':module' => 'system'])
->fetchField();
$version_string = unserialize($result);
}
else {
$version_string = FALSE;
}
return $version_string ? substr($version_string, 0, 1) : FALSE;
}
}
name: 'Drupal Upgrade UI'
type: module
description: 'UI for direct upgrades from older Drupal versions.'
package: 'Core (Experimental)'
version: VERSION
core: 8.x
configure: migrate_drupal_ui.upgrade
dependencies:
- migrate
- migrate_drupal
- dblog
<?php
/**
* @file
* Install, update, and uninstall functions for the migrate_drupal_ui module.
*/
use Drupal\Core\Url;
/**
* Implements hook_install().
*/
function migrate_drupal_ui_install() {
$url = Url::fromUri('base:upgrade')->toString();
drupal_set_message(t('The Drupal Upgrade UI module has been enabled. Proceed to the <a href=":url">upgrade form</a>.', [':url' => $url]));
}
<?php
/**
* @file
* Alert administrators before starting the import process.
*/
use Drupal\Core\Routing\RouteMatchInterface;
/**
* Implements hook_help().
*/
function migrate_drupal_ui_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
case 'help.page.migrate_drupal_ui':
$output = '<p>' . t('The Drupal Upgrade UI module provides a one-click upgrade from an earlier version of Drupal. For details, see the <a href=":migrate">online documentation for the Drupal Upgrade UI module</a> in the handbook on upgrading from previous versions.', [':migrate' => 'https://www.drupal.org/upgrade/migrate']) . '</p>';
return $output;
}
}
migrate_drupal_ui.upgrade:
path: '/upgrade'
defaults:
_form: '\Drupal\migrate_drupal_ui\Form\MigrateUpgradeForm'
_title: 'Upgrade'
requirements:
_permission: 'administer software updates'
options:
_admin_route: TRUE
migrate_drupal_ui.log:
path: '/upgrade/log'
defaults:
_controller: '\Drupal\migrate_drupal_ui\Controller\MigrateController::showLog'
requirements:
_permission: 'administer software updates'
options:
_admin_route: TRUE
<?php
/**
* @file
* Contains \Drupal\migrate_drupal_ui\Controller\MigrateController.
*/
namespace Drupal\migrate_drupal_ui\Controller;
use Drupal\Core\Controller\ControllerBase;
/**
* Provides controller methods for the migration.
*/
class MigrateController extends ControllerBase {
/**
* Sets a log filter and redirects to the log.
*
* @return \Symfony\Component\HttpFoundation\RedirectResponse
* A redirect response object that may be returned by the controller.
*/
public function showLog() {
$_SESSION['dblog_overview_filter'] = [];
$_SESSION['dblog_overview_filter']['type'] = ['migrate_drupal_ui' => 'migrate_drupal_ui'];
return $this->redirect('dblog.overview');
}
}
This diff is collapsed.
<?php
/**
* @file
* Contains \Drupal\migrate_drupal_ui\MigrateMessageCapture.
*/
namespace Drupal\migrate_drupal_ui;
use Drupal\migrate\MigrateMessageInterface;
/**
* Allows capturing messages rather than displaying them directly.
*/
class MigrateMessageCapture implements MigrateMessageInterface {
/**
* Array of recorded messages.
*
* @var array
*/
protected $messages = [];
/**
* {@inheritdoc}
*/
public function display($message, $type = 'status') {
$this->messages[] = $message;
}
/**
* Clears out any captured messages.
*/
public function clear() {
$this->messages = [];
}
/**
* Returns any captured messages.
*
* @return array
* The captured messages.
*/
public function getMessages() {
return $this->messages;
}
}
This diff is collapsed.
<?php
/**
* @file
* Contains \Drupal\migrate_drupal_ui\Tests\MigrateUpgradeTestBase.
*/
namespace Drupal\migrate_drupal_ui\Tests;
use Drupal\Core\Database\Database;
use Drupal\simpletest\WebTestBase;
/**
* Provides a base class for testing migration upgrades in the UI.
*/
abstract class MigrateUpgradeTestBase extends WebTestBase {
/**
* Use the Standard profile to test help implementations of many core modules.
*/
protected $profile = 'standard';
/**
* The source database connection.
*
* @var \Drupal\Core\Database\Connection
*/
protected $sourceDatabase;
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['migrate_drupal_ui'];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->createMigrationConnection();
$this->sourceDatabase = Database::getConnection('default', 'migrate_drupal_ui');
// Create and log in as user 1. Migrations in the UI can only be performed
// as user 1 once https://www.drupal.org/node/2675066 lands.
$this->drupalLogin($this->rootUser);
}
/**
* Loads a database fixture into the source database connection.
*
* @param string $path
* Path to the dump file.
*/
protected function loadFixture($path) {
$default_db = Database::getConnection()->getKey();
Database::setActiveConnection($this->sourceDatabase->getKey());
if (substr($path, -3) == '.gz') {
$path = 'compress.zlib://' . $path;
}
require $path;
Database::setActiveConnection($default_db);
}
/**
* Changes the database connection to the prefixed one.
*
* @todo Remove when we don't use global. https://www.drupal.org/node/2552791
*/
protected function createMigrationConnection() {
$connection_info = Database::getConnectionInfo('default')['default'];
if ($connection_info['driver'] === 'sqlite') {
// Create database file in the test site's public file directory so that
// \Drupal\simpletest\TestBase::restoreEnvironment() will delete this once
// the test is complete.
$file = $this->publicFilesDirectory . '/' . $this->testId . '-migrate.db.sqlite';
touch($file);
$connection_info['database'] = $file;
$connection_info['prefix'] = '';
}
else {
$prefix = is_array($connection_info['prefix']) ? $connection_info['prefix']['default'] : $connection_info['prefix'];
// Simpletest uses fixed length prefixes. Create a new prefix for the
// source database. Adding to the end of the prefix ensures that
// \Drupal\simpletest\TestBase::restoreEnvironment() will remove the
// additional tables.
$connection_info['prefix'] = $prefix . '0';
}
Database::addConnectionInfo('migrate_drupal_ui', 'default', $connection_info);
}
/**
* {@inheritdoc}
*/
protected function tearDown() {
Database::removeConnection('migrate_drupal_ui');
parent::tearDown();
}
/**
* Executes all steps of migrations upgrade.
*/
protected function testMigrateUpgrade() {
$connection_options = $this->sourceDatabase->getConnectionOptions();
$this->drupalGet('/upgrade');
$this->assertText('Upgrade a Drupal site by importing it into a clean and empty new install of Drupal 8. You will lose any existing configuration once you import your site into it. See the upgrading handbook for more detailed information.');
$this->drupalPostForm(NULL, [], t('Continue'));
$this->assertText('Provide credentials for the database of the Drupal site you want to upgrade.');
$this->assertFieldByName('mysql[host]');
$driver = $connection_options['driver'];
$connection_options['prefix'] = $connection_options['prefix']['default'];
// Use the driver connection form to get the correct options out of the
// database settings. This supports all of the databases we test against.
$drivers = drupal_get_database_types();
$form = $drivers[$driver]->getFormOptions($connection_options);
$connection_options = array_intersect_key($connection_options, $form + $form['advanced_options']);
$edits = $this->translatePostValues([
'driver' => $driver,
$driver => $connection_options,
'source_base_path' => $this->getSourceBasePath(),
]);
$this->drupalPostForm(NULL, $edits, t('Review upgrade'));
$this->assertResponse(200);
$this->assertText('Are you sure?');
$this->drupalPostForm(NULL, [], t('Perform upgrade'));
$this->assertText(t('Congratulations, you upgraded Drupal!'));
// Have to reset all the statics after migration to ensure entities are
// loadable.
$this->resetAll();
$expected_counts = $this->getEntityCounts();
foreach (array_keys(\Drupal::entityTypeManager()->getDefinitions()) as $entity_type) {
$real_count = count(\Drupal::entityTypeManager()->getStorage($entity_type)->loadMultiple());
$expected_count = isset($expected_counts[$entity_type]) ? $expected_counts[$entity_type] : 0;
$this->assertEqual($expected_count, $real_count, "Found $real_count $entity_type entities, expected $expected_count.");
}
}
/**
* Gets the source base path for the concrete test.
*
* @return string
* The source base path.
*/
abstract protected function getSourceBasePath();
/**
* Gets the expected number of entities per entity type after migration.
*
* @return int[]
* An array of expected counts keyed by entity type ID.
*/
abstract protected function getEntityCounts();
}
<?php
/**
* @file
* Contains \Drupal\migrate_drupal_ui\Tests\d6\MigrateUpgrade6Test.
*/
namespace Drupal\migrate_drupal_ui\Tests\d6;
use Drupal\migrate_drupal_ui\Tests\MigrateUpgradeTestBase;
/**
* Tests Drupal 6 upgrade using the migrate UI.
*
* The test method is provided by the MigrateUpgradeTestBase class.
*
* @group migrate_drupal_ui
*/
class MigrateUpgrade6Test extends MigrateUpgradeTestBase {
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->loadFixture(drupal_get_path('module', 'migrate_drupal') . '/tests/fixtures/drupal6.php');
}
/**
* {@inheritdoc}
*/
protected function getSourceBasePath() {
return __DIR__ . '/files';
}
/**
* {@inheritdoc}
*/
protected function getEntityCounts() {
return [
'block' => 30,
'block_content' => 2,
'block_content_type' => 1,
'comment' => 3,
'comment_type' => 2,
'contact_form' => 5,
'editor' => 2,
'field_config' => 61,
'field_storage_config' => 42,
'file' => 4,
'filter_format' => 8,
'image_style' => 5,
'migration' => 105,
'node' => 9,
'node_type' => 11,
'rdf_mapping' => 5,
'search_page' => 2,
'shortcut' => 2,
'shortcut_set' => 1,
'action' => 22,
'menu' => 8,
'taxonomy_term' => 6,
'taxonomy_vocabulary' => 6,
'tour' => 1,
'user' => 7,
'user_role' => 6,
'menu_link_content' => 4,
'view' => 12,
'date_format' => 11,
'entity_form_display' => 15,
'entity_form_mode' => 1,
'entity_view_display' => 32,
'entity_view_mode' => 12,
'base_field_override' => 33,
];
}
}
<?php
/**
* @file
* Contains \Drupal\migrate_drupal_ui\Tests\d7\MigrateUpgrade7Test.
*/
namespace Drupal\migrate_drupal_ui\Tests\d7;
use Drupal\migrate_drupal_ui\Tests\MigrateUpgradeTestBase;
/**
* Tests Drupal 7 upgrade using the migrate UI.
*
* The test method is provided by the MigrateUpgradeTestBase class.
*
* @group migrate_drupal_ui
*/
class MigrateUpgrade7Test extends MigrateUpgradeTestBase {
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->loadFixture(drupal_get_path('module', 'migrate_drupal') . '/tests/fixtures/drupal7.php');
}
/**
* {@inheritdoc}
*/
protected function getSourceBasePath() {
return __DIR__ . '/files';
}
/**
* {@inheritdoc}
*/
protected function getEntityCounts() {
return [
'block' => 25,
'block_content' => 1,
'block_content_type' => 1,
'comment' => 1,
'comment_type' => 7,
'contact_form' => 3,
'editor' => 2,
'field_config' => 40,
'field_storage_config' => 30,
'file' => 0,
'filter_format' => 7,
'image_style' => 6,
'migration' => 59,
'node' => 2,
'node_type' => 6,
'rdf_mapping' => 5,
'search_page' => 2,
'shortcut' => 6,
'shortcut_set' => 2,
'action' => 18,
'menu' => 10,
'taxonomy_term' => 18,
'taxonomy_vocabulary' => 3,
'tour' => 1,
'user' => 3,
'user_role' => 4,
'menu_link_content' => 9,
'view' => 12,
'date_format' => 11,
'entity_form_display' => 15,
'entity_form_mode' => 1,
'entity_view_display' => 22,
'entity_view_mode' => 10,
'base_field_override' => 7,
];