From f838dbc871a800c0a99bf83f11de850a42422cab Mon Sep 17 00:00:00 2001
From: catch <catch@35733.no-reply.drupal.org>
Date: Mon, 18 Jan 2021 21:43:55 +0000
Subject: [PATCH] Issue #3129534 by daffie, anmolgoyal74, mondrake,
 naresh_bavaskar, Beakerboy, catch, alexpott, quietone: Automatically enable
 the module that is providing the current database driver

---
 core/core.services.yml                        |  6 ++
 core/includes/install.inc                     | 13 +++
 core/lib/Drupal/Core/Database/Connection.php  | 18 ++++
 .../DatabaseDriverUninstallValidator.php      | 68 ++++++++++++++
 .../DatabaseDriverUninstallValidator.php      | 88 +++++++++++++++++++
 core/modules/system/system.install            | 18 ++++
 .../DatabaseDriverProvidedByModuleTest.php    | 72 +++++++++++++++
 .../InstallerNonDefaultDatabaseDriverTest.php | 28 ++++++
 .../Drupal/KernelTests/KernelTestBase.php     | 17 +++-
 ...KernelTestBaseDatabaseDriverModuleTest.php | 68 ++++++++++++++
 10 files changed, 393 insertions(+), 3 deletions(-)
 create mode 100644 core/lib/Drupal/Core/Extension/DatabaseDriverUninstallValidator.php
 create mode 100644 core/lib/Drupal/Core/ProxyClass/Extension/DatabaseDriverUninstallValidator.php
 create mode 100644 core/modules/system/tests/src/Functional/System/DatabaseDriverProvidedByModuleTest.php
 create mode 100644 core/tests/Drupal/KernelTests/KernelTestBaseDatabaseDriverModuleTest.php

diff --git a/core/core.services.yml b/core/core.services.yml
index 4f1b5ab35c4f..9dc264e5bee9 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -556,6 +556,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 8e6eb21d81ad..3983773e81d4 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,18 @@ function drupal_install_system($install_state) {
     ->set('profile', $install_state['parameters']['profile'])
     ->save();
 
+  $connection = Database::getConnection();
+  $provider = $connection->getProvider();
+  // 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 ($provider !== 'core') {
+    $autoload = $connection->getConnectionOptions()['autoload'] ?? '';
+    if (($pos = strpos($autoload, 'src/Driver/Database/')) !== FALSE) {
+      $kernel->getContainer()->get('module_installer')->install([$provider], FALSE);
+    }
+  }
+
   // Install System module.
   $kernel->getContainer()->get('module_installer')->install(['system'], FALSE);
 
diff --git a/core/lib/Drupal/Core/Database/Connection.php b/core/lib/Drupal/Core/Database/Connection.php
index 486d788b2c05..3f194e1aa052 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 000000000000..ed741de3c4dd
--- /dev/null
+++ b/core/lib/Drupal/Core/Extension/DatabaseDriverUninstallValidator.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Drupal\Core\Extension;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Database\Database;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+
+/**
+ * Ensures installed modules providing a database driver are not uninstalled.
+ */
+class DatabaseDriverUninstallValidator implements ModuleUninstallValidatorInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The module extension list.
+   *
+   * @var \Drupal\Core\Extension\ModuleExtensionList
+   */
+  protected $moduleExtensionList;
+
+  /**
+   * The database connection.
+   *
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $connection;
+
+  /**
+   * Constructs a new DatabaseDriverUninstallValidator.
+   *
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
+   *   The string translation service.
+   * @param \Drupal\Core\Extension\ModuleExtensionList $extension_list_module
+   *   The module extension list.
+   * @param \Drupal\Core\Database\Connection $connection
+   *   The database connection.
+   */
+  public function __construct(TranslationInterface $string_translation, ModuleExtensionList $extension_list_module, Connection $connection) {
+    $this->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 000000000000..2390c26f8c5e
--- /dev/null
+++ b/core/lib/Drupal/Core/ProxyClass/Extension/DatabaseDriverUninstallValidator.php
@@ -0,0 +1,88 @@
+<?php
+// @codingStandardsIgnoreFile
+
+/**
+ * This file was generated via php core/scripts/generate-proxy-class.php 'Drupal\Core\Extension\DatabaseDriverUninstallValidator' "core/lib/Drupal/Core".
+ */
+
+namespace Drupal\Core\ProxyClass\Extension {
+
+    /**
+     * Provides a proxy class for \Drupal\Core\Extension\DatabaseDriverUninstallValidator.
+     *
+     * @see \Drupal\Component\ProxyBuilder
+     */
+    class DatabaseDriverUninstallValidator implements \Drupal\Core\Extension\ModuleUninstallValidatorInterface
+    {
+
+        use \Drupal\Core\DependencyInjection\DependencySerializationTrait;
+
+        /**
+         * The id of the original proxied service.
+         *
+         * @var string
+         */
+        protected $drupalProxyOriginalServiceId;
+
+        /**
+         * The real proxied service, after it was lazy loaded.
+         *
+         * @var \Drupal\Core\Extension\DatabaseDriverUninstallValidator
+         */
+        protected $service;
+
+        /**
+         * The service container.
+         *
+         * @var \Symfony\Component\DependencyInjection\ContainerInterface
+         */
+        protected $container;
+
+        /**
+         * Constructs a ProxyClass Drupal proxy object.
+         *
+         * @param \Symfony\Component\DependencyInjection\ContainerInterface $container
+         *   The container.
+         * @param string $drupal_proxy_original_service_id
+         *   The service ID of the original service.
+         */
+        public function __construct(\Symfony\Component\DependencyInjection\ContainerInterface $container, $drupal_proxy_original_service_id)
+        {
+            $this->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/system/system.install b/core/modules/system/system.install
index e4cb7cef7c09..2b1a2a59b395 100644
--- a/core/modules/system/system.install
+++ b/core/modules/system/system.install
@@ -1126,6 +1126,24 @@ function system_requirements($phase) {
     }
   }
 
+  // When the database driver is provided by a module, then check that the
+  // providing module is enabled.
+  if ($phase === 'runtime' || $phase === 'update') {
+    $connection = Database::getConnection();
+    $provider = $connection->getProvider();
+    if ($provider !== 'core' && !\Drupal::moduleHandler()->moduleExists($provider)) {
+      $autoload = $connection->getConnectionOptions()['autoload'] ?? '';
+      if (($pos = strpos($autoload, 'src/Driver/Database/')) !== FALSE) {
+        $requirements['database_driver_provided_by_module'] = [
+          'title' => t('Database driver provided by module'),
+          'value' => t('Not enabled'),
+          'description' => t('The current database driver is provided by the module: %module. The module is currently not enabled. You should immediately <a href=":enable">enable</a> the module.', ['%module' => $provider, ':enable' => Url::fromRoute('system.modules_list')->toString()]),
+          'severity' => REQUIREMENT_ERROR,
+        ];
+      }
+    }
+  }
+
   // Check xdebug.max_nesting_level, as some pages will not work if it is too
   // low.
   if (extension_loaded('xdebug')) {
diff --git a/core/modules/system/tests/src/Functional/System/DatabaseDriverProvidedByModuleTest.php b/core/modules/system/tests/src/Functional/System/DatabaseDriverProvidedByModuleTest.php
new file mode 100644
index 000000000000..e81ced70d303
--- /dev/null
+++ b/core/modules/system/tests/src/Functional/System/DatabaseDriverProvidedByModuleTest.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Drupal\Tests\system\Functional\System;
+
+use Drupal\Core\Database\Database;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests output on the status overview page.
+ *
+ * @group system
+ */
+class DatabaseDriverProvidedByModuleTest extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $admin_user = $this->drupalCreateUser([
+      'administer site configuration',
+    ]);
+    $this->drupalLogin($admin_user);
+  }
+
+  /**
+   * Tests that the status page shows the error message.
+   */
+  public function testDatabaseDriverIsProvidedByModuleButTheModuleIsNotEnabled(): void {
+    $driver = Database::getConnection()->driver();
+    if (!in_array($driver, ['mysql', 'pgsql'])) {
+      $this->markTestSkipped("This test does not support the {$driver} database driver.");
+    }
+
+    // Change the default database connection to use the one from the module
+    // driver_test.
+    $connection_info = Database::getConnectionInfo();
+    $database = [
+      'database' => $connection_info['default']['database'],
+      'username' => $connection_info['default']['username'],
+      'password' => $connection_info['default']['password'],
+      'prefix' => $connection_info['default']['prefix'],
+      'host' => $connection_info['default']['host'],
+      'driver' => 'Drivertest' . ucfirst($driver),
+      'namespace' => 'Drupal\\driver_test\\Driver\\Database\\Drivertest' . ucfirst($driver),
+      'autoload' => 'core/modules/system/tests/modules/driver_test/src/Driver/Database/Drivertest' . ucfirst($driver),
+    ];
+    if (isset($connection_info['default']['port'])) {
+      $database['port'] = $connection_info['default']['port'];
+    }
+    $settings['databases']['default']['default'] = (object) [
+      'value'    => $database,
+      'required' => TRUE,
+    ];
+    $this->writeSettings($settings);
+
+    $this->drupalGet('admin/reports/status');
+    $this->assertSession()->statusCodeEquals(200);
+
+    // The module driver_test is not enabled and is providing to current
+    // database driver. Check that the correct error is shown.
+    $this->assertSession()->pageTextContains('Database driver provided by module');
+    $this->assertSession()->pageTextContains('The current database driver is provided by the module: driver_test. The module is currently not enabled. You should immediately enable the module.');
+  }
+
+}
diff --git a/core/tests/Drupal/FunctionalTests/Installer/InstallerNonDefaultDatabaseDriverTest.php b/core/tests/Drupal/FunctionalTests/Installer/InstallerNonDefaultDatabaseDriverTest.php
index 2aae61144517..f29ecde22c28 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 a5edce16dfae..e5afa6bba918 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 000000000000..b72e2972e26b
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/KernelTestBaseDatabaseDriverModuleTest.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Drupal\KernelTests;
+
+use Drupal\Core\Database\Database;
+
+/**
+ * @coversDefaultClass \Drupal\KernelTests\KernelTestBase
+ *
+ * @group PHPUnit
+ * @group Test
+ * @group KernelTests
+ */
+class KernelTestBaseDatabaseDriverModuleTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getDatabaseConnectionInfo() {
+    // If the test is run with argument SIMPLETEST_DB then use it.
+    $db_url = getenv('SIMPLETEST_DB');
+    if (empty($db_url)) {
+      throw new \Exception('There is no database connection so no tests can be run. You must provide a SIMPLETEST_DB environment variable to run PHPUnit based functional tests outside of run-tests.sh. See https://www.drupal.org/node/2116263#skipped-tests for more information.');
+    }
+    else {
+      $database = Database::convertDbUrlToConnectionInfo($db_url, $this->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);
+  }
+
+}
-- 
GitLab