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');
+ }
+
+}