Commit ae702dc5 authored by catch's avatar catch

Issue #1808248 by alexpott, beejeebus, tayzlor, Nitesh Sethia: Add a separate...

Issue #1808248 by alexpott, beejeebus, tayzlor, Nitesh Sethia: Add a separate module install/uninstall step to the config import process.
parent 8866760f
......@@ -612,4 +612,14 @@ public static function formBuilder() {
return static::$container->get('form_builder');
}
/**
* Gets the syncing state.
*
* @return bool
* Returns TRUE is syncing flag set.
*/
public function isConfigSyncing() {
return static::$container->get('config.installer')->isSyncing();
}
}
......@@ -14,10 +14,34 @@
*/
class BatchConfigImporter extends ConfigImporter {
/**
* The total number of extensions to process.
*
* @var int
*/
protected $totalExtensionsToProcess = 0;
/**
* The total number of configuration objects to process.
*
* @var int
*/
protected $totalConfigurationToProcess = 0;
/**
* Initializes the config importer in preparation for processing a batch.
*
* @return array
* An array of method names that to be called by the batch. If there are
* modules or themes to process then an extra step is added.
*
* @throws ConfigImporterException
* If the configuration is already importing.
*/
public function initialize() {
$batch_operations = array();
$this->createExtensionChangelist();
// Ensure that the changes have been validated.
$this->validate();
......@@ -25,61 +49,137 @@ public function initialize() {
// Another process is synchronizing configuration.
throw new ConfigImporterException(sprintf('%s is already importing', static::LOCK_ID));
}
$this->totalToProcess = 0;
foreach(array('create', 'delete', 'update') as $op) {
$this->totalToProcess += count($this->getUnprocessed($op));
$modules = $this->getUnprocessedExtensions('module');
foreach (array('install', 'uninstall') as $op) {
$this->totalExtensionsToProcess += count($modules[$op]);
}
$themes = $this->getUnprocessedExtensions('theme');
foreach (array('enable', 'disable') as $op) {
$this->totalExtensionsToProcess += count($themes[$op]);
}
// We have extensions to process.
if ($this->totalExtensionsToProcess > 0) {
$batch_operations[] = 'processExtensionBatch';
}
$batch_operations[] = 'processConfigurationBatch';
$batch_operations[] = 'finishBatch';
return $batch_operations;
}
/**
* Processes batch.
* Processes extensions as a batch operation.
*
* @param array $context.
* The batch context.
*/
public function processBatch(array &$context) {
$operation = $this->getNextOperation();
public function processExtensionBatch(array &$context) {
$operation = $this->getNextExtensionOperation();
if (!empty($operation)) {
$this->process($operation['op'], $operation['name']);
$context['message'] = t('Synchronizing @name.', array('@name' => $operation['name']));
$context['finished'] = $this->batchProgress();
$this->processExtension($operation['type'], $operation['op'], $operation['name']);
$context['message'] = t('Synchronising extensions: @op @name.', array('@op' => $operation['op'], '@name' => $operation['name']));
$processed_count = count($this->processedExtensions['module']['install']) + count($this->processedExtensions['module']['uninstall']);
$processed_count += count($this->processedExtensions['theme']['disable']) + count($this->processedExtensions['theme']['enable']);
$context['finished'] = $processed_count / $this->totalExtensionsToProcess;
}
else {
$context['finished'] = 1;
}
if ($context['finished'] >= 1) {
$this->eventDispatcher->dispatch(ConfigEvents::IMPORT, new ConfigImporterEvent($this));
// The import is now complete.
$this->lock->release(static::LOCK_ID);
$this->reset();
}
/**
* Processes configuration as a batch operation.
*
* @param array $context.
* The batch context.
*/
public function processConfigurationBatch(array &$context) {
// The first time this is called we need to calculate the total to process.
// This involves recalculating the changelist which will ensure that if
// extensions have been processed any configuration affected will be taken
// into account.
if ($this->totalConfigurationToProcess == 0) {
$this->storageComparer->reset();
foreach (array('delete', 'create', 'update') as $op) {
$this->totalConfigurationToProcess += count($this->getUnprocessedConfiguration($op));
}
}
$operation = $this->getNextConfigurationOperation();
if (!empty($operation)) {
$this->processConfiguration($operation['op'], $operation['name']);
$context['message'] = t('Synchronizing configuration: @op @name.', array('@op' => $operation['op'], '@name' => $operation['name']));
$processed_count = count($this->processedConfiguration['create']) + count($this->processedConfiguration['delete']) + count($this->processedConfiguration['update']);
$context['finished'] = $processed_count / $this->totalConfigurationToProcess;
}
else {
$context['finished'] = 1;
}
}
/**
* Gets percentage of progress made.
* Finishes the batch.
*
* @param array $context.
* The batch context.
*/
public function finishBatch(array &$context) {
$this->eventDispatcher->dispatch(ConfigEvents::IMPORT, new ConfigImporterEvent($this));
// The import is now complete.
$this->lock->release(static::LOCK_ID);
$this->reset();
$context['message'] = t('Finalising configuration synchronisation.');
$context['finished'] = 1;
}
/**
* Gets the next extension operation to perform.
*
* @return float
* The percentage of progress made expressed as a float between 0 and 1.
* @return array|bool
* An array containing the next operation and extension name to perform it
* on. If there is nothing left to do returns FALSE;
*/
protected function batchProgress() {
$processed_count = count($this->processed['create']) + count($this->processed['delete']) + count($this->processed['update']);
return $processed_count / $this->totalToProcess;
protected function getNextExtensionOperation() {
foreach (array('install', 'uninstall') as $op) {
$modules = $this->getUnprocessedExtensions('module');
if (!empty($modules[$op])) {
return array(
'op' => $op,
'type' => 'module',
'name' => array_shift($modules[$op]),
);
}
}
foreach (array('enable', 'disable') as $op) {
$themes = $this->getUnprocessedExtensions('theme');
if (!empty($themes[$op])) {
return array(
'op' => $op,
'type' => 'theme',
'name' => array_shift($themes[$op]),
);
}
}
return FALSE;
}
/**
* Gets the next operation to perform.
* Gets the next configuration operation to perform.
*
* @return array|bool
* An array containing the next operation and configuration name to perform
* it on. If there is nothing left to do returns FALSE;
*/
protected function getNextOperation() {
foreach(array('create', 'delete', 'update') as $op) {
$names = $this->getUnprocessed($op);
if (!empty($names)) {
protected function getNextConfigurationOperation() {
// The order configuration operations is processed is important. Deletes
// have to come first so that recreates can work.
foreach (array('delete', 'create', 'update') as $op) {
$config_names = $this->getUnprocessedConfiguration($op);
if (!empty($config_names)) {
return array(
'op' => $op,
'name' => array_shift($names),
'name' => array_shift($config_names),
);
}
}
......
......@@ -48,6 +48,20 @@ class ConfigInstaller implements ConfigInstallerInterface {
*/
protected $eventDispatcher;
/**
* The configuration storage that provides the default configuration.
*
* @var \Drupal\Core\Config\StorageInterface
*/
protected $sourceStorage;
/**
* Is configuration being created as part of a configuration sync.
*
* @var bool
*/
protected $isSyncing = FALSE;
/**
* Constructs the configuration installer.
*
......@@ -75,7 +89,7 @@ public function __construct(ConfigFactoryInterface $config_factory, StorageInter
*/
public function installDefaultConfig($type, $name) {
// Get all default configuration owned by this extension.
$source_storage = new ExtensionInstallStorage($this->activeStorage);
$source_storage = $this->getSourceStorage();
$config_to_install = $source_storage->listAll($name . '.');
// Work out if this extension provides default configuration for any other
......@@ -130,6 +144,16 @@ public function installDefaultConfig($type, $name) {
$new_config->setData($data[$name]);
}
if ($entity_type = $this->configManager->getEntityTypeIdByName($name)) {
// If we are syncing do not create configuration entities. Pluggable
// configuration entities can have dependencies on modules that are
// not yet enabled. This approach means that any code that expects
// default configuration entities to exist will be unstable after the
// module has been enabled and before the config entity has been
// imported.
if ($this->isSyncing) {
continue;
}
$entity_storage = $this->configManager
->getEntityManager()
->getStorage($entity_type);
......@@ -159,4 +183,49 @@ public function installDefaultConfig($type, $name) {
$this->configFactory->reset();
}
/**
* {@inheritdoc}
*/
public function setSourceStorage(StorageInterface $storage) {
$this->sourceStorage = $storage;
return $this;
}
/**
* {@inheritdoc}
*/
public function resetSourceStorage() {
$this->sourceStorage = null;
return $this;
}
/**
* Gets the configuration storage that provides the default configuration.
*
* @return \Drupal\Core\Config\StorageInterface
* The configuration storage that provides the default configuration.
*/
public function getSourceStorage() {
if (!isset($this->sourceStorage)) {
// Default to using the ExtensionInstallStorage which searches extension's
// config directories for default configuration.
$this->sourceStorage = new ExtensionInstallStorage($this->activeStorage);
}
return $this->sourceStorage;
}
/**
* {@inheritdoc}
*/
public function setSyncing($status) {
$this->isSyncing = $status;
return $this;
}
/**
* {@inheritdoc}
*/
public function isSyncing() {
return $this->isSyncing;
}
}
......@@ -37,4 +37,38 @@ interface ConfigInstallerInterface {
*/
public function installDefaultConfig($type, $name);
/**
* Sets the configuration storage that provides the default configuration.
*
* @param \Drupal\Core\Config\StorageInterface $storage
*
* @return self
* The configuration installer.
*/
public function setSourceStorage(StorageInterface $storage);
/**
* Resets the configuration storage that provides the default configuration.
*
* @return self
* The configuration installer.
*/
public function resetSourceStorage();
/**
* Sets the status of the isSyncing flag.
*
* @param bool $status
* The status of the sync flag.
*/
public function setSyncing($status);
/**
* Gets the syncing state.
*
* @return bool
* Returns TRUE is syncing flag set.
*/
public function isSyncing();
}
......@@ -90,6 +90,13 @@ public function getEntityManager() {
return $this->entityManager;
}
/**
* {@inheritdoc}
*/
public function getConfigFactory() {
return $this->configFactory;
}
/**
* {@inheritdoc}
*/
......
......@@ -31,6 +31,14 @@ public function getEntityTypeIdByName($name);
*/
public function getEntityManager();
/**
* Gets the config factory.
*
* @return \Drupal\Core\Config\ConfigFactoryInterface
* The entity manager.
*/
public function getConfigFactory();
/**
* Return a formatted diff of a named config between two storages.
*
......
......@@ -6,6 +6,8 @@
*/
namespace Drupal\Core\Config;
use Drupal\Component\Utility\String;
use Drupal\Core\Config\Entity\ConfigDependencyManager;
/**
......@@ -118,11 +120,23 @@ public function getChangelist($op = NULL) {
* The change operation performed. Either delete, create or update.
* @param array $changes
* Array of changes to add to the changelist.
* @param array $sort_order
* Array to sort that can be used to sort the changelist. This array must
* contain all the items that are in the change list.
*/
protected function addChangeList($op, array $changes) {
protected function addChangeList($op, array $changes, array $sort_order = NULL) {
// Only add changes that aren't already listed.
$changes = array_diff($changes, $this->changelist[$op]);
$this->changelist[$op] = array_merge($this->changelist[$op], $changes);
if (isset($sort_order)) {
$count = count($this->changelist[$op]);
// Sort the changlist in the same order as the $sort_order array and
// ensure the array is keyed from 0.
$this->changelist[$op] = array_values(array_intersect($sort_order, $this->changelist[$op]));
if ($count != count($this->changelist[$op])) {
throw new \InvalidArgumentException(String::format('Sorting the @op changelist should not change its length.', array('@op' => $op)));
}
}
}
/**
......@@ -188,8 +202,9 @@ protected function addChangelistUpdate() {
if (!empty($recreates)) {
// Recreates should become deletes and creates. Deletes should be ordered
// so that dependencies are deleted first.
$this->addChangeList('create', $recreates);
$this->addChangeList('delete', array_reverse($recreates));
$this->addChangeList('create', $recreates, $this->sourceNames);
$this->addChangeList('delete', $recreates, array_reverse($this->targetNames));
}
}
......
......@@ -28,7 +28,7 @@ class ConfigImportSubscriber implements EventSubscriberInterface {
*/
public function onConfigImporterValidate(ConfigImporterEvent $event) {
foreach (array('delete', 'create', 'update') as $op) {
foreach ($event->getConfigImporter()->getUnprocessed($op) as $name) {
foreach ($event->getConfigImporter()->getUnprocessedConfiguration($op) as $name) {
Config::validateName($name);
}
}
......
......@@ -582,6 +582,12 @@ public function install(array $module_list, $enable_dependencies = TRUE) {
// Required for module installation checks.
include_once DRUPAL_ROOT . '/core/includes/install.inc';
/** @var \Drupal\Core\Config\ConfigInstaller $config_installer */
$config_installer = \Drupal::service('config.installer');
$sync_status = $config_installer->isSyncing();
if ($sync_status) {
$source_storage = $config_installer->getSourceStorage();
}
$modules_installed = array();
foreach ($module_list as $module) {
$enabled = $extension_config->get("module.$module") !== NULL;
......@@ -671,6 +677,18 @@ public function install(array $module_list, $enable_dependencies = TRUE) {
}
// Install default configuration of the module.
$config_installer = \Drupal::service('config.installer');
if ($sync_status) {
$config_installer
->setSyncing(TRUE)
->setSourceStorage($source_storage);
}
else {
// If we're not in a config synchronisation reset the source storage
// so that the extension install storage will pick up the new
// configuration.
$config_installer->resetSourceStorage();
}
\Drupal::service('config.installer')->installDefaultConfig('module', $module);
// If the module has no current updates, but has some that were
......@@ -732,7 +750,7 @@ public function uninstall(array $module_list, $uninstall_dependents = TRUE) {
// Skip already uninstalled modules.
if (isset($installed_modules[$dependent]) && !isset($module_list[$dependent]) && $dependent != $profile) {
$module_list[$dependent] = TRUE;
$module_list[$dependent] = $dependent;
}
}
}
......
......@@ -146,6 +146,15 @@ public function enable(array $theme_list) {
// Refresh the theme list as installation of default configuration needs
// an updated list to work.
$this->reset();
// The default config installation storage only knows about the currently
// enabled list of themes, so it has to be reset in order to pick up the
// default config of the newly installed theme. However, do not reset the
// source storage when synchronizing configuration, since that would
// needlessly trigger a reload of the whole configuration to be imported.
if (!$this->configInstaller->isSyncing()) {
$this->configInstaller->resetSourceStorage();
}
// Install default configuration of the theme.
$this->configInstaller->installDefaultConfig('theme', $key);
}
......
......@@ -8,6 +8,7 @@
use Drupal\block\BlockInterface;
use Drupal\Component\Plugin\Exception\PluginException;
use Drupal\language\Entity\Language;
use Drupal\system\Entity\Menu;
use Symfony\Cmf\Component\Routing\RouteObjectInterface;
/**
......@@ -422,10 +423,12 @@ function block_user_role_delete($role) {
/**
* Implements hook_menu_delete().
*/
function block_menu_delete($menu) {
foreach (entity_load_multiple('block') as $block) {
if ($block->get('plugin') == 'system_menu_block:' . $menu->id()) {
$block->delete();
function block_menu_delete(Menu $menu) {
if (!$menu->isSyncing()) {
foreach (entity_load_multiple('block') as $block) {
if ($block->get('plugin') == 'system_menu_block:' . $menu->id()) {
$block->delete();
}
}
}
}
......
......@@ -184,6 +184,14 @@ public function addDefaultField($entity_type, $bundle, $field_name = 'comment',
))
->save();
// The comment field should be hidden in all other form displays.
foreach ($this->entityManager->getFormModes($entity_type) as $id => $form_mode) {
$display = entity_get_form_display($entity_type, $bundle, $id);
// Only update existing displays.
if ($display && !$display->isNew()) {
$display->removeComponent($field_name)->save();
}
}
// Set default to display comment list.
entity_get_display($entity_type, $bundle, 'default')
->setComponent($field_name, array(
......@@ -192,6 +200,15 @@ public function addDefaultField($entity_type, $bundle, $field_name = 'comment',
'weight' => 20,
))
->save();
// The comment field should be hidden in all other view displays.
foreach ($this->entityManager->getViewModes($entity_type) as $id => $view_mode) {
$display = entity_get_display($entity_type, $bundle, $id);
// Only update existing displays.
if ($display && !$display->isNew()) {
$display->removeComponent($field_name)->save();
}
}
}
$this->addBodyField($entity_type, $field_name);
}
......
......@@ -7,6 +7,10 @@
namespace Drupal\config\Form;
use Drupal\Component\Uuid\UuidInterface;
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Extension\ThemeHandlerInterface;
use Drupal\Core\Config\ConfigManagerInterface;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Config\StorageInterface;
......@@ -72,6 +76,20 @@ class ConfigSync extends FormBase {
*/
protected $typedConfigManager;
/**
* The module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* The theme handler.
*
* @var \Drupal\Core\Extension\ThemeHandlerInterface
*/
protected $themeHandler;
/**
* Constructs the object.
*
......@@ -89,8 +107,12 @@ class ConfigSync extends FormBase {
* The url generator service.
* @param \Drupal\Core\Config\TypedConfigManager $typed_config
* The typed configuration manager.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler
* @param \Drupal\Core\Extension\ThemeHandlerInterface $theme_handler
* The theme handler
*/
public function __construct(StorageInterface $sourceStorage, StorageInterface $targetStorage, LockBackendInterface $lock, EventDispatcherInterface $event_dispatcher, ConfigManagerInterface $config_manager, UrlGeneratorInterface $url_generator, TypedConfigManager $typed_config) {
public function __construct(StorageInterface $sourceStorage, StorageInterface $targetStorage, LockBackendInterface $lock, EventDispatcherInterface $event_dispatcher, ConfigManagerInterface $config_manager, UrlGeneratorInterface $url_generator, TypedConfigManager $typed_config, ModuleHandlerInterface $module_handler, ThemeHandlerInterface $theme_handler) {
$this->sourceStorage = $sourceStorage;
$this->targetStorage = $targetStorage;
$this->lock = $lock;
......@@ -98,6 +120,8 @@ public function __construct(StorageInterface $sourceStorage, StorageInterface $t
$this->configManager = $config_manager;
$this->urlGenerator = $url_generator;
$this->typedConfigManager = $typed_config;
$this->moduleHandler = $module_handler;
$this->themeHandler = $theme_handler;
}
/**
......@@ -111,7 +135,9 @@ public static function create(ContainerInterface $container) {
$container->get('event_dispatcher'),
$container->get('config.manager'),
$container->get('url_generator'),
$container->get('config.typed')
$container->get('config.typed'),
$container->get('module_handler'),
$container->get('theme_handler')
);
}
......@@ -222,24 +248,27 @@ public function submitForm(array &$form, array &$form_state) {
$this->eventDispatcher,
$this->configManager,
$this->lock,
$this->typedConfigManager
$this->typedConfigManager,
$this->moduleHandler,
$this->themeHandler
);
if ($config_importer->alreadyImporting()) {
drupal_set_message($this->t('Another request may be synchronizing configuration already.'));
}
else{
$config_importer->initialize();
$operations = $config_importer->initialize();
$batch = array(
'operations' => array(
array(array(get_class($this), 'processBatch'), array($config_importer)),
),
'operations' => array(),
'finished' => array(get_class($this), 'finishBatch'),
'title' => t('Synchronizing configuration'),
'init_message' => t('Starting configuration synchronization.'),
'progress_message' => t('Synchronized @current configuration files out of @total.'),
'progress_message' => t('Completed @current step of @total.'),
'error_message' => t('Configuration synchronization has encountered an error.'),
'file' => drupal_get_path('module', 'config') . '/config.admin.inc',
);
foreach ($operations as $operation) {
$batch['operations'][] = array(array(get_class($this), 'processBatch'), array($config_importer, $operation));
}
batch_set($batch);
}
......@@ -253,13 +282,13 @@ public function submitForm(array &$form, array &$form_state) {
* @param $context
* The batch context.
*/
public static function processBatch(BatchConfigImporter $config_importer, &$context) {
public static function processBatch(BatchConfigImporter $config_importer, $operation, &$context) {
if (!isset($context['sandbox']['config_importer'])) {
$context['sandbox']['config_importer'] = $config_importer;
}
$config_importer = $context['sandbox']['config_importer'];
$config_importer->processBatch($context);
$config_importer->$operation($context);
}
/**
......
<?php
/**
* @file
* Contains \Drupal\config\Tests\ConfigImportAllTest.
*/
namespace Drupal\config\Tests;
use Drupal\Core\Config\StorageComparer;
use Drupal\system\Tests\Module\ModuleTestBase;
class ConfigImportAllTest extends ModuleTestBase {
/**
* The profile to install as a basis for testing.
*
* Using the standard profile as this has a lot of additional configuration.
*
* @var string
*/
protected $profile = 'standard';
public static function getInfo() {
return array(
'name' => 'Import configuration from all modules and the standard profile',
'description' => 'Tests the largest configuration import possible with the modules and profiles provided by core.',
'group' => 'Configuration',
);
}
/**
* Tests that a fixed set of modules can be installed and uninstalled.
*/
public function testInstallUninstall() {
// Get a list of modules to enable.