diff --git a/core/core.services.yml b/core/core.services.yml index c705398371..a74388cfab 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -554,6 +554,12 @@ services: - { name: module_install.uninstall_validator } arguments: ['@string_translation', '@extension.list.module', '@extension.list.theme'] lazy: true + database_driver_uninstall_validator: + class: Drupal\Core\Extension\DatabaseDriverUninstallValidator + tags: + - { name: module_install.uninstall_validator } + arguments: ['@string_translation', '@extension.list.module', '@database'] + lazy: true theme_handler: class: Drupal\Core\Extension\ThemeHandler arguments: ['%app.root%', '@config.factory', '@extension.list.theme'] diff --git a/core/includes/install.inc b/core/includes/install.inc index 8e6eb21d81..02ef87dfab 100644 --- a/core/includes/install.inc +++ b/core/includes/install.inc @@ -8,6 +8,7 @@ use Drupal\Component\Utility\OpCodeCache; use Drupal\Component\Utility\Unicode; use Drupal\Component\Utility\UrlHelper; +use Drupal\Core\Database\Database; use Drupal\Core\Extension\Dependency; use Drupal\Core\Extension\ExtensionDiscovery; use Drupal\Core\Installer\InstallerKernel; @@ -619,6 +620,22 @@ function drupal_install_system($install_state) { ->set('profile', $install_state['parameters']['profile']) ->save(); + $connection = Database::getConnection(); + $module = $connection ? $connection->getProvider() : 'core'; + // When the database driver is provided by a module, then install that module. + // This module must be installed before any other module, as it must be able + // to override any call to hook_schema() or any "backend_overridable" service. + if ($module !== 'core') { + $autoload = $connection->getConnectionOptions()['autoload'] ?? ''; + if (($pos = strpos($autoload, 'src/Driver/Database/')) !== FALSE) { + $module_info_yml = substr($autoload, 0, $pos) . $module . '.info.yml'; + if (file_exists($module_info_yml)) { + \Drupal::service('extension.list.module')->setPathname($module, $module_info_yml); + $kernel->getContainer()->get('module_installer')->install([$module], FALSE); + } + } + } + // Install System module. $kernel->getContainer()->get('module_installer')->install(['system'], FALSE); diff --git a/core/lib/Drupal/Core/Command/DbCommandBase.php b/core/lib/Drupal/Core/Command/DbCommandBase.php index d5a80e08d2..f7dd9da89a 100644 --- a/core/lib/Drupal/Core/Command/DbCommandBase.php +++ b/core/lib/Drupal/Core/Command/DbCommandBase.php @@ -55,7 +55,19 @@ protected function getDatabaseConnection(InputInterface $input) { Database::addConnectionInfo($key, 'default', $info); } - return Database::getConnection('default', $key); + $connection = Database::getConnection('default', $key); + + // When the database driver is provided by a module, then that module must + // be enabled. + $module = $connection->getProvider(); + if ($module !== 'core' && !\Drupal::moduleHandler()->moduleExists($module)) { + $autoload = $connection->getConnectionOptions()['autoload'] ?? ''; + if (($pos = strpos($autoload, 'src/Driver/Database/')) !== FALSE) { + \Drupal::service('module_installer')->install([$module]); + } + } + + return $connection; } } diff --git a/core/lib/Drupal/Core/Database/Connection.php b/core/lib/Drupal/Core/Database/Connection.php index 486d788b2c..3f194e1aa0 100644 --- a/core/lib/Drupal/Core/Database/Connection.php +++ b/core/lib/Drupal/Core/Database/Connection.php @@ -1950,4 +1950,22 @@ public static function createUrlFromConnectionOptions(array $connection_options) return $db_url; } + /** + * Get the module name of the module that is providing the database driver. + * + * @return string + * The module name of the module that is providing the database driver, or + * "core" when the driver is not provided as part of a module. + */ + public function getProvider(): string { + [$first, $second] = explode('\\', $this->connectionOptions['namespace'], 3); + + // The namespace for Drupal modules is Drupal\MODULE_NAME, and the module + // name must be all lowercase. Second-level namespaces containing uppercase + // letters (e.g., "Core", "Component", "Driver") are not modules. + // @see \Drupal\Core\DrupalKernel::getModuleNamespacesPsr4() + // @see https://www.drupal.org/docs/8/creating-custom-modules/naming-and-placing-your-drupal-8-module#s-name-your-module + return ($first === 'Drupal' && strtolower($second) === $second) ? $second : 'core'; + } + } diff --git a/core/lib/Drupal/Core/Extension/DatabaseDriverUninstallValidator.php b/core/lib/Drupal/Core/Extension/DatabaseDriverUninstallValidator.php new file mode 100644 index 0000000000..ed741de3c4 --- /dev/null +++ b/core/lib/Drupal/Core/Extension/DatabaseDriverUninstallValidator.php @@ -0,0 +1,68 @@ +stringTranslation = $string_translation; + $this->moduleExtensionList = $extension_list_module; + $this->connection = $connection; + } + + /** + * {@inheritdoc} + */ + public function validate($module) { + $reasons = []; + + // @todo Remove the next line of code in + // https://www.drupal.org/project/drupal/issues/3129043. + $this->connection = Database::getConnection(); + + // When the database driver is provided by a module, then that module + // cannot be uninstalled. + if ($module === $this->connection->getProvider()) { + $module_name = $this->moduleExtensionList->get($module)->info['name']; + $reasons[] = $this->t("The module '@module_name' is providing the database driver '@driver_name'.", + ['@module_name' => $module_name, '@driver_name' => $this->connection->driver()]); + } + + return $reasons; + } + +} diff --git a/core/lib/Drupal/Core/ProxyClass/Extension/DatabaseDriverUninstallValidator.php b/core/lib/Drupal/Core/ProxyClass/Extension/DatabaseDriverUninstallValidator.php new file mode 100644 index 0000000000..2390c26f8c --- /dev/null +++ b/core/lib/Drupal/Core/ProxyClass/Extension/DatabaseDriverUninstallValidator.php @@ -0,0 +1,88 @@ +container = $container; + $this->drupalProxyOriginalServiceId = $drupal_proxy_original_service_id; + } + + /** + * Lazy loads the real service from the container. + * + * @return object + * Returns the constructed real service. + */ + protected function lazyLoadItself() + { + if (!isset($this->service)) { + $this->service = $this->container->get($this->drupalProxyOriginalServiceId); + } + + return $this->service; + } + + /** + * {@inheritdoc} + */ + public function validate($module) + { + return $this->lazyLoadItself()->validate($module); + } + + /** + * {@inheritdoc} + */ + public function setStringTranslation(\Drupal\Core\StringTranslation\TranslationInterface $translation) + { + return $this->lazyLoadItself()->setStringTranslation($translation); + } + + } + +} diff --git a/core/modules/migrate/src/Plugin/migrate/source/SqlBase.php b/core/modules/migrate/src/Plugin/migrate/source/SqlBase.php index cf13b46392..a20793cb09 100644 --- a/core/modules/migrate/src/Plugin/migrate/source/SqlBase.php +++ b/core/modules/migrate/src/Plugin/migrate/source/SqlBase.php @@ -210,6 +210,17 @@ protected function setUpDatabase(array $database_info) { throw $e; } } + + // When the database driver is provided by a module, then that module must + // be enabled. + $module = $connection->getProvider(); + if ($module !== 'core' && !\Drupal::moduleHandler()->moduleExists($module)) { + $autoload = $connection->getConnectionOptions()['autoload'] ?? ''; + if (($pos = strpos($autoload, 'src/Driver/Database/')) !== FALSE) { + \Drupal::service('module_installer')->install([$module]); + } + } + return $connection; } diff --git a/core/modules/migrate/tests/src/Kernel/SqlBaseTest.php b/core/modules/migrate/tests/src/Kernel/SqlBaseTest.php index 3dae860d52..b9dad6e380 100644 --- a/core/modules/migrate/tests/src/Kernel/SqlBaseTest.php +++ b/core/modules/migrate/tests/src/Kernel/SqlBaseTest.php @@ -101,6 +101,32 @@ public function testConnectionTypes() { $this->assertSame($sql_base->getDatabase()->getTarget(), $target); $this->assertSame($sql_base->getDatabase()->getKey(), $key); + // Test that when the migrate database driver is provided by a module, that + // the providing module is enabled by calling ::getDatabase(). + $driver = Database::getConnection()->driver(); + if (in_array($driver, ['mysql', 'pgsql'])) { + $target = 'test_db_target_provided_by_module'; + $key = 'test_migrate_connection_provided_by_module'; + $config = ['target' => $target, 'key' => $key]; + $database = Database::getConnectionInfo('default')['default']; + $database['driver'] = 'Drivertest' . ucfirst($driver); + $database['namespace'] = 'Drupal\driver_test\Driver\Database\Drivertest' . ucfirst($driver); + $database['autoload'] = 'core/modules/system/tests/modules/driver_test/src/Driver/Database/Drivertest' . ucfirst($driver) . '/'; + $sql_base->setConfiguration($config); + Database::addConnectionInfo($key, $target, $database); + + // Confirm that the driver_test module has not been enabled before calling + // getDatabase(). + $this->assertFalse(\Drupal::moduleHandler()->moduleExists('driver_test')); + + // Call getDatabase() to get the connection defined. + $sql_base->getDatabase(); + + // Confirm that the driver_test module is enabled after calling + // getDatabase(). + $this->assertTrue(\Drupal::moduleHandler()->moduleExists('driver_test')); + } + // Now test we can have SqlBase create the connection from an info array. $sql_base = new TestSqlBase([], $this->migration); diff --git a/core/modules/system/tests/src/Kernel/Scripts/DbCommandBaseTest.php b/core/modules/system/tests/src/Kernel/Scripts/DbCommandBaseTest.php index 0b31ec6db9..e57840a0d3 100644 --- a/core/modules/system/tests/src/Kernel/Scripts/DbCommandBaseTest.php +++ b/core/modules/system/tests/src/Kernel/Scripts/DbCommandBaseTest.php @@ -102,6 +102,40 @@ public function testPrefix() { // $this->assertEquals('notsimpletest', $command->getDatabaseConnection($command_tester->getInput())->tablePrefix()); } + /** + * Test database driver provided by a module. + */ + public function testDatabaseDriverProvidedByModule() { + $command = new DbCommandBaseTester(); + $command_tester = new CommandTester($command); + + $driver = Database::getConnection()->driver(); + if (!in_array($driver, ['mysql', 'pgsql'])) { + $this->markTestSkipped(); + } + + $database = Database::getConnectionInfo('default')['default']; + $database['driver'] = 'Drivertest' . ucfirst($driver); + $database['namespace'] = 'Drupal\driver_test\Driver\Database\Drivertest' . ucfirst($driver); + $database['autoload'] = 'core/modules/system/tests/modules/driver_test/src/Driver/Database/Drivertest' . ucfirst($driver) . '/'; + + $key = 'database_provided_by_module'; + Database::addConnectionInfo($key, 'default', $database); + + // Confirm that the driver_test module has not been enabled before calling + // getDatabaseConnection(). + $this->assertFalse(\Drupal::moduleHandler()->moduleExists('driver_test')); + + $command_tester->execute([ + '--database' => $key, + ]); + $this->assertEquals($key, $command->getDatabaseConnection($command_tester->getInput())->getKey()); + + // Confirm that the driver_test module is enabled after calling + // getDatabaseConnection(). + $this->assertTrue(\Drupal::moduleHandler()->moduleExists('driver_test')); + } + } /** diff --git a/core/tests/Drupal/FunctionalTests/Installer/InstallerNonDefaultDatabaseDriverTest.php b/core/tests/Drupal/FunctionalTests/Installer/InstallerNonDefaultDatabaseDriverTest.php index 2aae611445..f29ecde22c 100644 --- a/core/tests/Drupal/FunctionalTests/Installer/InstallerNonDefaultDatabaseDriverTest.php +++ b/core/tests/Drupal/FunctionalTests/Installer/InstallerNonDefaultDatabaseDriverTest.php @@ -3,6 +3,8 @@ namespace Drupal\FunctionalTests\Installer; use Drupal\Core\Database\Database; +use Drupal\Core\Extension\Extension; +use Drupal\Core\Extension\ModuleUninstallValidatorException; /** * Tests the interactive installer. @@ -60,6 +62,32 @@ public function testInstalled() { $this->assertStringContainsString("'namespace' => 'Drupal\\\\driver_test\\\\Driver\\\\Database\\\\{$this->testDriverName}',", $contents); $this->assertStringContainsString("'driver' => '{$this->testDriverName}',", $contents); $this->assertStringContainsString("'autoload' => 'core/modules/system/tests/modules/driver_test/src/Driver/Database/{$this->testDriverName}/',", $contents); + + // Assert that the module "driver_test" has been installed. + $this->assertEquals(\Drupal::service('module_handler')->getModule('driver_test'), new Extension($this->root, 'module', 'core/modules/system/tests/modules/driver_test/driver_test.info.yml')); + + // Change the default database connection to use the database driver from + // the module "driver_test". + $connection_info = Database::getConnectionInfo(); + $driver_test_connection = $connection_info['default']; + $driver_test_connection['driver'] = $this->testDriverName; + $driver_test_connection['namespace'] = 'Drupal\\driver_test\\Driver\\Database\\' . $this->testDriverName; + $driver_test_connection['autoload'] = "core/modules/system/tests/modules/driver_test/src/Driver/Database/{$this->testDriverName}/"; + Database::renameConnection('default', 'original_database_connection'); + Database::addConnectionInfo('default', 'default', $driver_test_connection); + + // The module "driver_test" should not be uninstallable, because it is + // providing the database driver. + try { + $this->container->get('module_installer')->uninstall(['driver_test']); + $this->fail('Uninstalled driver_test module.'); + } + catch (ModuleUninstallValidatorException $e) { + $this->assertStringContainsString("The module 'Contrib database driver test' is providing the database driver '{$this->testDriverName}'.", $e->getMessage()); + } + + // Restore the old database connection. + Database::addConnectionInfo('default', 'default', $connection_info['default']); } } diff --git a/core/tests/Drupal/KernelTests/KernelTestBase.php b/core/tests/Drupal/KernelTests/KernelTestBase.php index a5edce16df..e5afa6bba9 100644 --- a/core/tests/Drupal/KernelTests/KernelTestBase.php +++ b/core/tests/Drupal/KernelTests/KernelTestBase.php @@ -336,6 +336,20 @@ private function bootKernel() { $modules = self::getModulesToEnable(static::class); + // When a module is providing the database driver, then enable that module. + $connection_info = Database::getConnectionInfo(); + $driver = $connection_info['default']['driver']; + $namespace = $connection_info['default']['namespace'] ?? NULL; + $autoload = $connection_info['default']['autoload'] ?? NULL; + if (strpos($autoload, 'src/Driver/Database/') !== FALSE) { + [$first, $second] = explode('\\', $namespace, 3); + if ($first === 'Drupal' && strtolower($second) === $second) { + // Add the module that provides the database driver to the list of + // modules as the first to be enabled. + array_unshift($modules, $second); + } + } + // Bootstrap the kernel. Do not use createFromRequest() to retain Settings. $kernel = new DrupalKernel('testing', $this->classLoader, FALSE); $kernel->setSitePath($this->siteDirectory); @@ -357,9 +371,6 @@ private function bootKernel() { // Ensure database tasks have been run. require_once __DIR__ . '/../../../includes/install.inc'; - $connection_info = Database::getConnectionInfo(); - $driver = $connection_info['default']['driver']; - $namespace = $connection_info['default']['namespace'] ?? NULL; $errors = db_installer_object($driver, $namespace)->runTasks(); if (!empty($errors)) { $this->fail('Failed to run installer database tasks: ' . implode(', ', $errors)); diff --git a/core/tests/Drupal/KernelTests/KernelTestBaseDatabaseDriverModuleTest.php b/core/tests/Drupal/KernelTests/KernelTestBaseDatabaseDriverModuleTest.php new file mode 100644 index 0000000000..b72e2972e2 --- /dev/null +++ b/core/tests/Drupal/KernelTests/KernelTestBaseDatabaseDriverModuleTest.php @@ -0,0 +1,68 @@ +root); + + if (in_array($database['driver'], ['mysql', 'pgsql'])) { + // Change the used database driver to the one provided by the module + // "driver_test". + $driver = 'Drivertest' . ucfirst($database['driver']); + $database['driver'] = $driver; + $database['namespace'] = 'Drupal\\driver_test\\Driver\\Database\\' . $driver; + $database['autoload'] = "core/modules/system/tests/modules/driver_test/src/Driver/Database/$driver/"; + } + + Database::addConnectionInfo('default', 'default', $database); + } + + // Clone the current connection and replace the current prefix. + $connection_info = Database::getConnectionInfo('default'); + if (!empty($connection_info)) { + Database::renameConnection('default', 'simpletest_original_default'); + foreach ($connection_info as $target => $value) { + // Replace the full table prefix definition to ensure that no table + // prefixes of the test runner leak into the test. + $connection_info[$target]['prefix'] = [ + 'default' => $this->databasePrefix, + ]; + } + } + return $connection_info; + } + + /** + * @covers ::bootEnvironment + */ + public function testDatabaseDriverModuleEnabled(): void { + $driver = Database::getConnection()->driver(); + if (!in_array($driver, ['DrivertestMysql', 'DrivertestPgsql'])) { + $this->markTestSkipped("This test does not support the {$driver} database driver."); + } + + // Test that the module that is providing the database driver is enabled. + $this->assertSame(1, \Drupal::service('extension.list.module')->get('driver_test')->status); + } + +}