diff --git a/core/core.services.yml b/core/core.services.yml index 07def88bc56..053c40b764c 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1286,6 +1286,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 1ddb3960d3c..58cc8b9844c 100644 --- a/core/lib/Drupal/Core/Config/ConfigImporter.php +++ b/core/lib/Drupal/Core/Config/ConfigImporter.php @@ -673,13 +673,18 @@ class ConfigImporter { /** * 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 99b933754d0..0547a14d3ac 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\ConfigImporter; 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 @@ class ConfigImportSubscriber extends ConfigImportValidateEventSubscriberBase { $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 @@ class ConfigImportSubscriber extends ConfigImportValidateEventSubscriberBase { $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 @@ class ConfigImportSubscriber extends ConfigImportValidateEventSubscriberBase { $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 @@ class ConfigImportSubscriber extends ConfigImportValidateEventSubscriberBase { // 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 00000000000..061b025119f --- /dev/null +++ b/core/lib/Drupal/Core/Extension/ConfigImportModuleUninstallValidatorInterface.php @@ -0,0 +1,34 @@ +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 @@ class ModuleRequiredByThemesUninstallValidator implements ModuleUninstallValidat * 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 d5bf3d9e8fc..01fd07ba89c 100644 --- a/core/lib/Drupal/Core/Extension/ModuleUninstallValidatorInterface.php +++ b/core/lib/Drupal/Core/Extension/ModuleUninstallValidatorInterface.php @@ -8,6 +8,14 @@ namespace Drupal\Core\Extension; * 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 6afd09d9dd3..c730a6c8acb 100644 --- a/core/lib/Drupal/Core/Extension/ThemeInstaller.php +++ b/core/lib/Drupal/Core/Extension/ThemeInstaller.php @@ -146,11 +146,11 @@ class ThemeInstaller implements ThemeInstallerInterface { 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 57eb24f15c8..cde2c6b4487 100644 --- a/core/lib/Drupal/Core/ProxyClass/Extension/ModuleRequiredByThemesUninstallValidator.php +++ b/core/lib/Drupal/Core/ProxyClass/Extension/ModuleRequiredByThemesUninstallValidator.php @@ -12,7 +12,7 @@ namespace Drupal\Core\ProxyClass\Extension { * * @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 @@ namespace Drupal\Core\ProxyClass\Extension { 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 b4666f30a6e..f3102060121 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 @@ use Drupal\Core\StringTranslation\TranslationInterface; /** * Prevents uninstallation of modules providing active field storage. */ -class FieldUninstallValidator implements ModuleUninstallValidatorInterface { +class FieldUninstallValidator implements ConfigImportModuleUninstallValidatorInterface { use StringTranslationTrait; @@ -72,6 +73,15 @@ class FieldUninstallValidator implements ModuleUninstallValidatorInterface { 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 4d16c5709bc..63d21697548 100644 --- a/core/tests/Drupal/KernelTests/Core/Config/ConfigImporterTest.php +++ b/core/tests/Drupal/KernelTests/Core/Config/ConfigImporterTest.php @@ -660,6 +660,30 @@ class ConfigImporterTest extends KernelTestBase { } } + /** + * 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 System 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 00000000000..f967b432bda --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Theme/ConfigImportThemeInstallTest.php @@ -0,0 +1,82 @@ +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 Test Module Required by Theme 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 Test Theme Depending on Modules theme since it requires the Test Module Required by Theme 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'); + } + +}