diff --git a/core/core.services.yml b/core/core.services.yml index e39adb47d9f5f053b1e98b7fc7ff0caad877e3e1..6c7c54c504dc69dac61171f21cea9939cd05620a 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1254,6 +1254,7 @@ services: class: Drupal\Core\EventSubscriber\ConfigImportSubscriber tags: - { name: event_subscriber } + - { name: service_collector, tag: 'module_install.uninstall_validator', call: addUninstallValidator } arguments: ['@theme_handler', '@extension.list.module'] config_snapshot_subscriber: class: Drupal\Core\EventSubscriber\ConfigSnapshotSubscriber diff --git a/core/lib/Drupal/Core/Config/ConfigImporter.php b/core/lib/Drupal/Core/Config/ConfigImporter.php index 1ddb3960d3cd833ea937c1c5ee2ad9ede5a85433..58cc8b9844c5395190946eb073dd2d63737f0e85 100644 --- a/core/lib/Drupal/Core/Config/ConfigImporter.php +++ b/core/lib/Drupal/Core/Config/ConfigImporter.php @@ -673,13 +673,18 @@ protected function finish(&$context) { /** * Gets the next extension operation to perform. * + * Uninstalls are processed first with themes coming before modules. Then + * installs are processed with modules coming before themes. This order is + * necessary because themes can depend on modules. + * * @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 getNextExtensionOperation() { - foreach (['module', 'theme'] as $type) { - foreach (['install', 'uninstall'] as $op) { + foreach (['uninstall', 'install'] as $op) { + $types = $op === 'uninstall' ? ['theme', 'module'] : ['module', 'theme']; + foreach ($types as $type) { $unprocessed = $this->getUnprocessedExtensions($type); if (!empty($unprocessed[$op])) { return [ diff --git a/core/lib/Drupal/Core/EventSubscriber/ConfigImportSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/ConfigImportSubscriber.php index 99b933754d0f12ec967f0aa3baa0b3dd1866490e..0547a14d3aca128e018aa8e41306b327e3876c5d 100644 --- a/core/lib/Drupal/Core/EventSubscriber/ConfigImportSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/ConfigImportSubscriber.php @@ -7,7 +7,9 @@ use Drupal\Core\Config\ConfigImporterEvent; use Drupal\Core\Config\ConfigImportValidateEventSubscriberBase; use Drupal\Core\Config\ConfigNameException; +use Drupal\Core\Extension\ConfigImportModuleUninstallValidatorInterface; use Drupal\Core\Extension\ModuleExtensionList; +use Drupal\Core\Extension\ModuleUninstallValidatorInterface; use Drupal\Core\Extension\ThemeHandlerInterface; use Drupal\Core\Installer\InstallerKernel; @@ -37,6 +39,13 @@ class ConfigImportSubscriber extends ConfigImportValidateEventSubscriberBase { */ protected $themeHandler; + /** + * The uninstall validators. + * + * @var \Drupal\Core\Extension\ModuleUninstallValidatorInterface[] + */ + protected $uninstallValidators = []; + /** * Constructs the ConfigImportSubscriber. * @@ -50,6 +59,16 @@ public function __construct(ThemeHandlerInterface $theme_handler, ModuleExtensio $this->moduleExtensionList = $extension_list_module; } + /** + * Adds a module uninstall validator. + * + * @param \Drupal\Core\Extension\ModuleUninstallValidatorInterface $uninstall_validator + * The uninstall validator to add. + */ + public function addUninstallValidator(ModuleUninstallValidatorInterface $uninstall_validator): void { + $this->uninstallValidators[] = $uninstall_validator; + } + /** * Validates the configuration to be imported. * @@ -150,6 +169,16 @@ protected function validateModules(ConfigImporter $config_importer) { $config_importer->logError($this->t('Unable to uninstall the %module module since the %dependent_module module is installed.', ['%module' => $module_name, '%dependent_module' => $dependent_module_name])); } } + // Ensure that modules can be uninstalled. + foreach ($this->uninstallValidators as $validator) { + $reasons = $validator instanceof ConfigImportModuleUninstallValidatorInterface ? + $validator->validateConfigImport($module, $config_importer->getStorageComparer()->getSourceStorage()) : + $validator->validate($module); + foreach ($reasons as $reason) { + $config_importer->logError($this->t('Unable to uninstall the %module module because: @reason.', + ['%module' => $module_data[$module]->info['name'], '@reason' => $reason])); + } + } } // Ensure that the install profile is not being uninstalled. @@ -169,6 +198,7 @@ protected function validateThemes(ConfigImporter $config_importer) { $core_extension = $config_importer->getStorageComparer()->getSourceStorage()->read('core.extension'); // Get all themes including those that are not installed. $theme_data = $this->getThemeData(); + $module_data = $this->moduleExtensionList->getList(); $installs = $config_importer->getExtensionChangelist('theme', 'install'); foreach ($installs as $key => $theme) { if (!isset($theme_data[$theme])) { @@ -181,13 +211,28 @@ protected function validateThemes(ConfigImporter $config_importer) { // Ensure that all themes being installed have their dependencies met. foreach ($installs as $theme) { - foreach (array_keys($theme_data[$theme]->requires) as $required_theme) { + $module_dependencies = $theme_data[$theme]->module_dependencies; + // $theme_data[$theme]->requires contains both theme and module + // dependencies keyed by the extension machine names. + // $theme_data[$theme]->module_dependencies contains only the module + // dependencies keyed by the module extension machine name. Therefore, we + // can find the theme dependencies by finding array keys for 'requires' + // that are not in $module_dependencies. + $theme_dependencies = array_diff_key($theme_data[$theme]->requires, $module_dependencies); + foreach (array_keys($theme_dependencies) as $required_theme) { if (!isset($core_extension['theme'][$required_theme])) { $theme_name = $theme_data[$theme]->info['name']; $required_theme_name = $theme_data[$required_theme]->info['name']; $config_importer->logError($this->t('Unable to install the %theme theme since it requires the %required_theme theme.', ['%theme' => $theme_name, '%required_theme' => $required_theme_name])); } } + foreach (array_keys($module_dependencies) as $required_module) { + if (!isset($core_extension['module'][$required_module])) { + $theme_name = $theme_data[$theme]->info['name']; + $required_module_name = $module_data[$required_module]->info['name']; + $config_importer->logError($this->t('Unable to install the %theme theme since it requires the %required_module module.', ['%theme' => $theme_name, '%required_module' => $required_module_name])); + } + } } // Ensure that all themes being uninstalled are not required by themes that diff --git a/core/lib/Drupal/Core/Extension/ConfigImportModuleUninstallValidatorInterface.php b/core/lib/Drupal/Core/Extension/ConfigImportModuleUninstallValidatorInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..061b025119f219da910f0575b355fc29b31fe3ee --- /dev/null +++ b/core/lib/Drupal/Core/Extension/ConfigImportModuleUninstallValidatorInterface.php @@ -0,0 +1,34 @@ +<?php + +namespace Drupal\Core\Extension; + +use Drupal\Core\Config\StorageInterface; + +/** + * Special interface for module uninstall validators for configuration import. + * + * A module uninstall validator that needs different functionality prior to a + * configuration import should implement this interface and be defined in + * a Drupal @link container service @endlink that is tagged + * module_install.uninstall_validator. + */ +interface ConfigImportModuleUninstallValidatorInterface extends ModuleUninstallValidatorInterface { + + /** + * Determines reasons a module can not be uninstalled prior to config import. + * + * @param string $module + * A module name. + * @param \Drupal\Core\Config\StorageInterface $source_storage + * Storage object used to read configuration that is about to be imported. + * + * @return string[] + * An array of reasons the module can not be uninstalled, empty if it can. + * Each reason should not end with any punctuation since multiple reasons + * can be displayed together. + * + * @see \Drupal\Core\EventSubscriber\ConfigImportSubscriber::validateModules() + */ + public function validateConfigImport(string $module, StorageInterface $source_storage): array; + +} diff --git a/core/lib/Drupal/Core/Extension/ModuleInstallerInterface.php b/core/lib/Drupal/Core/Extension/ModuleInstallerInterface.php index 0e4e05d7bf9b4007bcd931a5441f67206a237ccd..17c1ffe50c65104caaa0fa033f9223c05b4ed4be 100644 --- a/core/lib/Drupal/Core/Extension/ModuleInstallerInterface.php +++ b/core/lib/Drupal/Core/Extension/ModuleInstallerInterface.php @@ -70,7 +70,7 @@ public function install(array $module_list, $enable_dependencies = TRUE); public function uninstall(array $module_list, $uninstall_dependents = TRUE); /** - * Adds module a uninstall validator. + * Adds a module uninstall validator. * * @param \Drupal\Core\Extension\ModuleUninstallValidatorInterface $uninstall_validator * The uninstall validator to add. diff --git a/core/lib/Drupal/Core/Extension/ModuleRequiredByThemesUninstallValidator.php b/core/lib/Drupal/Core/Extension/ModuleRequiredByThemesUninstallValidator.php index 86499f55cad5d5028f9250e140591c8da7a7777a..1e98447303f92770b7857d8b959606d2c2267669 100644 --- a/core/lib/Drupal/Core/Extension/ModuleRequiredByThemesUninstallValidator.php +++ b/core/lib/Drupal/Core/Extension/ModuleRequiredByThemesUninstallValidator.php @@ -2,13 +2,14 @@ namespace Drupal\Core\Extension; +use Drupal\Core\Config\StorageInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\StringTranslation\TranslationInterface; /** * Ensures modules cannot be uninstalled if enabled themes depend on them. */ -class ModuleRequiredByThemesUninstallValidator implements ModuleUninstallValidatorInterface { +class ModuleRequiredByThemesUninstallValidator implements ConfigImportModuleUninstallValidatorInterface { use StringTranslationTrait; @@ -61,6 +62,27 @@ public function validate($module) { return $reasons; } + /** + * {@inheritdoc} + */ + public function validateConfigImport(string $module, StorageInterface $source_storage): array { + $reasons = []; + + $themes_depending_on_module = $this->getThemesDependingOnModule($module); + if (!empty($themes_depending_on_module)) { + $installed_themes_after_import = $source_storage->read('core.extension')['theme']; + $themes_depending_on_module_still_installed = array_intersect_key($themes_depending_on_module, $installed_themes_after_import); + // Ensure that any dependent themes will be uninstalled by the module. + if (!empty($themes_depending_on_module_still_installed)) { + $reasons[] = $this->formatPlural(count($themes_depending_on_module_still_installed), + 'Required by the theme: @theme_names', + 'Required by the themes: @theme_names', + ['@theme_names' => implode(', ', $themes_depending_on_module_still_installed)]); + } + } + return $reasons; + } + /** * Returns themes that depend on a module. * @@ -68,7 +90,8 @@ public function validate($module) { * The module machine name. * * @return string[] - * An array of the names of themes that depend on $module. + * An array of the names of themes that depend on $module keyed by the + * theme's machine name. */ protected function getThemesDependingOnModule($module) { $installed_themes = $this->themeExtensionList->getAllInstalledInfo(); diff --git a/core/lib/Drupal/Core/Extension/ModuleUninstallValidatorInterface.php b/core/lib/Drupal/Core/Extension/ModuleUninstallValidatorInterface.php index d5bf3d9e8fc0255fe4b81aefaa091412b9c8f5a1..01fd07ba89cda43ed6f136951f7cf84e0b9b3966 100644 --- a/core/lib/Drupal/Core/Extension/ModuleUninstallValidatorInterface.php +++ b/core/lib/Drupal/Core/Extension/ModuleUninstallValidatorInterface.php @@ -8,6 +8,14 @@ * A module uninstall validator must implement this interface and be defined in * a Drupal @link container service @endlink that is tagged * module_install.uninstall_validator. + * + * Validators are called during module uninstall and prior to running a + * configuration import. If different logic is required when uninstalling via + * configuration import implement ConfigImportModuleUninstallValidatorInterface. + * + * @see \Drupal\Core\Extension\ModuleInstaller::validateUninstall() + * @see \Drupal\Core\EventSubscriber\ConfigImportSubscriber::validateModules() + * @see \Drupal\Core\Extension\ConfigImportModuleUninstallValidatorInterface */ interface ModuleUninstallValidatorInterface { diff --git a/core/lib/Drupal/Core/Extension/ThemeInstaller.php b/core/lib/Drupal/Core/Extension/ThemeInstaller.php index 66b83967f5e9e397dbbd2916cdfbc9a24e1114d7..51362971443146061574af0b4aa34fd51a57a0fb 100644 --- a/core/lib/Drupal/Core/Extension/ThemeInstaller.php +++ b/core/lib/Drupal/Core/Extension/ThemeInstaller.php @@ -142,11 +142,11 @@ public function install(array $theme_list, $install_dependencies = TRUE) { foreach ($theme_list as $theme => $value) { $module_dependencies = $theme_data[$theme]->module_dependencies; // $theme_data[$theme]->requires contains both theme and module - // dependencies keyed by the extension machine names and - // $theme_data[$theme]->module_dependencies contains only modules keyed - // by the module extension machine name. Therefore we can find the theme - // dependencies by finding array keys for 'requires' that are not in - // $module_dependencies. + // dependencies keyed by the extension machine names. + // $theme_data[$theme]->module_dependencies contains only the module + // dependencies keyed by the module extension machine name. Therefore, + // we can find the theme dependencies by finding array keys for + // 'requires' that are not in $module_dependencies. $theme_dependencies = array_diff_key($theme_data[$theme]->requires, $module_dependencies); // We can find the unmet module dependencies by finding the module // machine names keys that are not in $installed_modules keys. diff --git a/core/lib/Drupal/Core/ProxyClass/Extension/ModuleRequiredByThemesUninstallValidator.php b/core/lib/Drupal/Core/ProxyClass/Extension/ModuleRequiredByThemesUninstallValidator.php index 57eb24f15c822e53ab37840f4be48ee46295e07a..cde2c6b44879857ccea043e07c8ed315f3b52efc 100644 --- a/core/lib/Drupal/Core/ProxyClass/Extension/ModuleRequiredByThemesUninstallValidator.php +++ b/core/lib/Drupal/Core/ProxyClass/Extension/ModuleRequiredByThemesUninstallValidator.php @@ -12,7 +12,7 @@ * * @see \Drupal\Component\ProxyBuilder */ - class ModuleRequiredByThemesUninstallValidator implements \Drupal\Core\Extension\ModuleUninstallValidatorInterface + class ModuleRequiredByThemesUninstallValidator implements \Drupal\Core\Extension\ConfigImportModuleUninstallValidatorInterface { use \Drupal\Core\DependencyInjection\DependencySerializationTrait; @@ -75,6 +75,14 @@ public function validate($module) return $this->lazyLoadItself()->validate($module); } + /** + * {@inheritdoc} + */ + public function validateConfigImport(string $module, \Drupal\Core\Config\StorageInterface $source_storage): array + { + return $this->lazyLoadItself()->validateConfigImport($module, $source_storage); + } + /** * {@inheritdoc} */ diff --git a/core/modules/field/src/FieldUninstallValidator.php b/core/modules/field/src/FieldUninstallValidator.php index b4666f30a6e5fa3fe9eebaa94be8d0c195134552..f3102060121328913481dee71429e09c375a30e3 100644 --- a/core/modules/field/src/FieldUninstallValidator.php +++ b/core/modules/field/src/FieldUninstallValidator.php @@ -2,8 +2,9 @@ namespace Drupal\field; +use Drupal\Core\Config\StorageInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; -use Drupal\Core\Extension\ModuleUninstallValidatorInterface; +use Drupal\Core\Extension\ConfigImportModuleUninstallValidatorInterface; use Drupal\Core\Field\FieldTypePluginManagerInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\StringTranslation\TranslationInterface; @@ -11,7 +12,7 @@ /** * Prevents uninstallation of modules providing active field storage. */ -class FieldUninstallValidator implements ModuleUninstallValidatorInterface { +class FieldUninstallValidator implements ConfigImportModuleUninstallValidatorInterface { use StringTranslationTrait; @@ -72,6 +73,15 @@ public function validate($module) { return $reasons; } + /** + * {@inheritdoc} + */ + public function validateConfigImport(string $module, StorageInterface $source_storage): array { + // The field_config_import_steps_alter() method removes field data prior to + // configuration import so the checks in ::validate() are unnecessary. + return []; + } + /** * Returns all field storages for a specified module. * diff --git a/core/tests/Drupal/KernelTests/Core/Config/ConfigImporterTest.php b/core/tests/Drupal/KernelTests/Core/Config/ConfigImporterTest.php index 4d16c5709bc5521fae14e3e6ba070ccadea7d7c6..63d21697548a823c71bd79a6ccb50d9174ad931c 100644 --- a/core/tests/Drupal/KernelTests/Core/Config/ConfigImporterTest.php +++ b/core/tests/Drupal/KernelTests/Core/Config/ConfigImporterTest.php @@ -660,6 +660,30 @@ public function testMissingCoreExtension() { } } + /** + * Tests uninstall validators being called during synchronization. + * + * @see \Drupal\Core\EventSubscriber\ConfigImportSubscriber + */ + public function testRequiredModuleValidation() { + $sync = $this->container->get('config.storage.sync'); + + $extensions = $sync->read('core.extension'); + unset($extensions['module']['system']); + $sync->write('core.extension', $extensions); + + $config_importer = $this->configImporter(); + try { + $config_importer->import(); + $this->fail('ConfigImporterException not thrown, invalid import was not stopped due to missing dependencies.'); + } + catch (ConfigImporterException $e) { + $this->assertStringContainsString('There were errors validating the config synchronization.', $e->getMessage()); + $error_log = $config_importer->getErrors(); + $this->assertEquals('Unable to uninstall the <em class="placeholder">System</em> module because: The System module is required.', $error_log[0]); + } + } + /** * Tests install profile validation during configuration import. * diff --git a/core/tests/Drupal/KernelTests/Core/Theme/ConfigImportThemeInstallTest.php b/core/tests/Drupal/KernelTests/Core/Theme/ConfigImportThemeInstallTest.php new file mode 100644 index 0000000000000000000000000000000000000000..f967b432bdaa94f447aa6a0e2d686ab2dff5bb85 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Theme/ConfigImportThemeInstallTest.php @@ -0,0 +1,82 @@ +<?php + +namespace Drupal\KernelTests\Core\Theme; + +use Drupal\Core\Config\ConfigImporterException; +use Drupal\KernelTests\KernelTestBase; + +/** + * Tests installing and uninstalling of themes via configuration import. + * + * @group Extension + */ +class ConfigImportThemeInstallTest extends KernelTestBase { + + /** + * Modules to enable. + * + * @var array + */ + protected static $modules = ['system']; + + protected function setUp(): void { + parent::setUp(); + $this->installConfig(['system']); + } + + /** + * Tests config imports that install and uninstall a theme with dependencies. + */ + public function testConfigImportWithThemeWithModuleDependencies() { + $this->container->get('module_installer')->install(['test_module_required_by_theme', 'test_another_module_required_by_theme']); + $this->container->get('theme_installer')->install(['test_theme_depending_on_modules']); + $this->assertTrue($this->container->get('theme_handler')->themeExists('test_theme_depending_on_modules'), 'test_theme_depending_on_modules theme installed'); + + $sync = $this->container->get('config.storage.sync'); + $this->copyConfig($this->container->get('config.storage'), $sync); + $extensions = $sync->read('core.extension'); + // Remove one of the modules the theme depends on. + unset($extensions['module']['test_module_required_by_theme']); + $sync->write('core.extension', $extensions); + + try { + $this->configImporter()->validate(); + $this->fail('ConfigImporterException not thrown; an invalid import was not stopped due to missing dependencies.'); + } + catch (ConfigImporterException $e) { + $error_message = 'Unable to uninstall the <em class="placeholder">Test Module Required by Theme</em> module because: Required by the theme: Test Theme Depending on Modules.'; + $this->assertStringContainsString($error_message, $e->getMessage(), 'There were errors validating the config synchronization.'); + $error_log = $this->configImporter->getErrors(); + $this->assertEquals([$error_message], $error_log); + } + + // Remove the other module and the theme. + unset($extensions['module']['test_another_module_required_by_theme']); + unset($extensions['theme']['test_theme_depending_on_modules']); + $sync->write('core.extension', $extensions); + $this->configImporter()->import(); + + $this->assertFalse($this->container->get('theme_handler')->themeExists('test_theme_depending_on_modules'), 'test_theme_depending_on_modules theme uninstalled by configuration import'); + + // Try installing a theme with dependencies via config import. + $extensions['theme']['test_theme_depending_on_modules'] = 0; + $extensions['module']['test_another_module_required_by_theme'] = 0; + $sync->write('core.extension', $extensions); + try { + $this->configImporter()->validate(); + $this->fail('ConfigImporterException not thrown; an invalid import was not stopped due to missing dependencies.'); + } + catch (ConfigImporterException $e) { + $error_message = 'Unable to install the <em class="placeholder">Test Theme Depending on Modules</em> theme since it requires the <em class="placeholder">Test Module Required by Theme</em> module.'; + $this->assertStringContainsString($error_message, $e->getMessage(), 'There were errors validating the config synchronization.'); + $error_log = $this->configImporter->getErrors(); + $this->assertEquals([$error_message], $error_log); + } + + $extensions['module']['test_module_required_by_theme'] = 0; + $sync->write('core.extension', $extensions); + $this->configImporter()->import(); + $this->assertTrue($this->container->get('theme_handler')->themeExists('test_theme_depending_on_modules'), 'test_theme_depending_on_modules theme installed'); + } + +}