diff --git a/core/assets/scaffold/files/default.settings.php b/core/assets/scaffold/files/default.settings.php index 82080861219..62f73e1b465 100644 --- a/core/assets/scaffold/files/default.settings.php +++ b/core/assets/scaffold/files/default.settings.php @@ -105,6 +105,16 @@ $databases = []; * webserver. For most other drivers, you must specify a * username, password, host, and database name. * + * Drupal core implements drivers for mysql, pgsql, and sqlite. Other drivers + * can be provided by contributed or custom modules. To use a contributed or + * custom driver, the "namespace" property must be set to the namespace of the + * driver. The code in this namespace must be autoloadable prior to connecting + * to the database, and therefore, prior to when module root namespaces are + * added to the autoloader. To add the driver's namespace to the autoloader, + * set the "autoload" property to the PSR-4 base directory of the driver's + * namespace. This is optional for projects managed with Composer if the + * driver's namespace is in Composer's autoloader. + * * Transaction support is enabled by default for all drivers that support it, * including MySQL. To explicitly disable it, set the 'transactions' key to * FALSE. @@ -224,6 +234,20 @@ $databases = []; * 'database' => '/path/to/databasefilename', * ]; * @endcode + * + * Sample Database configuration format for a driver in a contributed module: + * @code + * $databases['default']['default'] = [ + * 'driver' => 'mydriver', + * 'namespace' => 'Drupal\mymodule\Driver\Database\mydriver', + * 'autoload' => 'modules/mymodule/src/Driver/Database/mydriver/', + * 'database' => 'databasename', + * 'username' => 'sqlusername', + * 'password' => 'sqlpassword', + * 'host' => 'localhost', + * 'prefix' => '', + * ]; + * @endcode */ /** diff --git a/core/includes/install.core.inc b/core/includes/install.core.inc index d077d0a35a9..8ba1e13446d 100644 --- a/core/includes/install.core.inc +++ b/core/includes/install.core.inc @@ -375,6 +375,11 @@ function install_begin_request($class_loader, &$install_state) { ->addArgument(Settings::getInstance()) ->addArgument((new LoggerChannelFactory())->get('file')); + // Register the class loader so contrib and custom database drivers can be + // autoloaded. + // @see drupal_get_database_types() + $container->set('class_loader', $class_loader); + \Drupal::setContainer($container); // Determine whether base system services are ready to operate. @@ -1207,7 +1212,7 @@ function install_database_errors($database, $settings_file) { // calling function. Database::addConnectionInfo('default', 'default', $database); - $errors = db_installer_object($driver)->runTasks(); + $errors = db_installer_object($driver, $database['namespace'] ?? NULL)->runTasks(); } return $errors; } diff --git a/core/includes/install.inc b/core/includes/install.inc index e8a53f4e703..9a5a5f52f76 100644 --- a/core/includes/install.inc +++ b/core/includes/install.inc @@ -171,6 +171,8 @@ function drupal_get_database_types() { // The internal database driver name is any valid PHP identifier. $mask = ExtensionDiscovery::PHP_FUNCTION_PATTERN; + + // Find drivers in the Drupal\Core and Drupal\Driver namespaces. /** @var \Drupal\Core\File\FileSystemInterface $file_system */ $file_system = \Drupal::service('file_system'); $files = $file_system->scanDirectory(DRUPAL_ROOT . '/core/lib/Drupal/Core/Database/Driver', $mask, ['recurse' => FALSE]); @@ -179,11 +181,43 @@ function drupal_get_database_types() { } foreach ($files as $file) { if (file_exists($file->uri . '/Install/Tasks.php')) { - $drivers[$file->filename] = $file->uri; + // The namespace doesn't need to be added here, because + // db_installer_object() will find it. + $drivers[$file->filename] = NULL; } } - foreach ($drivers as $driver => $file) { - $installer = db_installer_object($driver); + + // Find drivers in Drupal module namespaces. + /** @var \Composer\Autoload\ClassLoader $class_loader */ + $class_loader = \Drupal::service('class_loader'); + // We cannot use the file cache because it does not always exist. + $extension_discovery = new ExtensionDiscovery(DRUPAL_ROOT, FALSE, []); + $modules = $extension_discovery->scan('module'); + foreach ($modules as $module) { + $module_driver_path = DRUPAL_ROOT . '/' . $module->getPath() . '/src/Driver/Database'; + if (is_dir($module_driver_path)) { + $driver_files = $file_system->scanDirectory($module_driver_path, $mask, ['recurse' => FALSE]); + foreach ($driver_files as $driver_file) { + $tasks_file = $module_driver_path . '/' . $driver_file->filename . '/Install/Tasks.php'; + if (file_exists($tasks_file)) { + $namespace = 'Drupal\\' . $module->getName() . '\\Driver\\Database\\' . $driver_file->filename; + + // The namespace needs to be added for db_installer_object() to find + // it. + $drivers[$driver_file->filename] = $namespace; + + // The directory needs to be added to the autoloader, because this is + // early in the installation process: the module hasn't been enabled + // yet and the database connection info array (including its 'autoload' + // key) hasn't been created yet. + $class_loader->addPsr4($namespace . '\\', $module->getPath() . '/src/Driver/Database/' . $driver_file->filename); + } + } + } + } + + foreach ($drivers as $driver => $namespace) { + $installer = db_installer_object($driver, $namespace); if ($installer->installable()) { $databases[$driver] = $installer; } @@ -1169,20 +1203,35 @@ function install_profile_info($profile, $langcode = 'en') { /** * Returns a database installer object. * + * Before calling this function it is important the database installer object + * is autoloadable. Database drivers provided by contributed modules are added + * to the autoloader in drupal_get_database_types() and Settings::initialize(). + * * @param $driver * The name of the driver. + * @param string $namespace + * (optional) The database driver namespace. * * @return \Drupal\Core\Database\Install\Tasks * A class defining the requirements and tasks for installing the database. + * + * @see drupal_get_database_types() + * @see \Drupal\Core\Site\Settings::initialize() */ -function db_installer_object($driver) { +function db_installer_object($driver, $namespace = NULL) { // We cannot use Database::getConnection->getDriverClass() here, because // the connection object is not yet functional. + if ($namespace) { + $task_class = $namespace . "\\Install\\Tasks"; + return new $task_class(); + } + // Old Drupal 8 style contrib namespace. $task_class = "Drupal\\Driver\\Database\\{$driver}\\Install\\Tasks"; if (class_exists($task_class)) { return new $task_class(); } else { + // Core provided driver. $task_class = "Drupal\\Core\\Database\\Driver\\{$driver}\\Install\\Tasks"; return new $task_class(); } diff --git a/core/lib/Drupal/Core/Database/Connection.php b/core/lib/Drupal/Core/Database/Connection.php index 5ac8d2e5b8c..216f12f1ba3 100644 --- a/core/lib/Drupal/Core/Database/Connection.php +++ b/core/lib/Drupal/Core/Database/Connection.php @@ -1378,7 +1378,7 @@ abstract class Connection { * Returns the type of database driver. * * This is not necessarily the same as the type of the database itself. For - * instance, there could be two MySQL drivers, mysql and mysql_mock. This + * instance, there could be two MySQL drivers, mysql and mysqlMock. This * function would return different values for each, but both would return * "mysql" for databaseType(). * @@ -1572,10 +1572,6 @@ abstract class Connection { /** * Creates an array of database connection options from a URL. * - * @internal - * This method should not be called. Use - * \Drupal\Core\Database\Database::convertDbUrlToConnectionInfo() instead. - * * @param string $url * The URL. * @param string $root @@ -1589,6 +1585,10 @@ abstract class Connection { * Exception thrown when the provided URL does not meet the minimum * requirements. * + * @internal + * This method should only be called from + * \Drupal\Core\Database\Database::convertDbUrlToConnectionInfo(). + * * @see \Drupal\Core\Database\Database::convertDbUrlToConnectionInfo() */ public static function createConnectionOptionsFromUrl($url, $root) { @@ -1634,12 +1634,10 @@ abstract class Connection { /** * Creates a URL from an array of database connection options. * - * @internal - * This method should not be called. Use - * \Drupal\Core\Database\Database::getConnectionInfoAsUrl() instead. - * * @param array $connection_options - * The array of connection options for a database connection. + * The array of connection options for a database connection. An additional + * key of 'module' is added by Database::getConnectionInfoAsUrl() for + * drivers provided my contributed or custom modules for convenience. * * @return string * The connection info as a URL. @@ -1648,6 +1646,10 @@ abstract class Connection { * Exception thrown when the provided array of connection options does not * meet the minimum requirements. * + * @internal + * This method should only be called from + * \Drupal\Core\Database\Database::getConnectionInfoAsUrl(). + * * @see \Drupal\Core\Database\Database::getConnectionInfoAsUrl() */ public static function createUrlFromConnectionOptions(array $connection_options) { @@ -1674,6 +1676,11 @@ abstract class Connection { $db_url .= '/' . $connection_options['database']; + // Add the module when the driver is provided by a module. + if (isset($connection_options['module'])) { + $db_url .= '?module=' . $connection_options['module']; + } + if (isset($connection_options['prefix']['default']) && $connection_options['prefix']['default'] !== '') { $db_url .= '#' . $connection_options['prefix']['default']; } diff --git a/core/lib/Drupal/Core/Database/Database.php b/core/lib/Drupal/Core/Database/Database.php index 173caf3ece5..870c2abce66 100644 --- a/core/lib/Drupal/Core/Database/Database.php +++ b/core/lib/Drupal/Core/Database/Database.php @@ -2,6 +2,9 @@ namespace Drupal\Core\Database; +use Composer\Autoload\ClassLoader; +use Drupal\Core\Extension\ExtensionDiscovery; + /** * Primary front-controller for the database system. * @@ -448,6 +451,8 @@ abstract class Database { * @throws \InvalidArgumentException * Exception thrown when the provided URL does not meet the minimum * requirements. + * @throws \RuntimeException + * Exception thrown when a module provided database driver does not exist. */ public static function convertDbUrlToConnectionInfo($url, $root) { // Check that the URL is well formed, starting with 'scheme://', where @@ -457,18 +462,130 @@ abstract class Database { } $driver = $matches[1]; - // Discover if the URL has a valid driver scheme. Try with custom drivers - // first, since those can override/extend the core ones. - $connection_class = $custom_connection_class = "Drupal\\Driver\\Database\\{$driver}\\Connection"; - if (!class_exists($connection_class)) { - // If the URL is not relative to a custom driver, try with core ones. - $connection_class = "Drupal\\Core\\Database\\Driver\\{$driver}\\Connection"; - if (!class_exists($connection_class)) { - throw new \InvalidArgumentException("Can not convert '$url' to a database connection, class '$custom_connection_class' does not exist"); + // Determine if the database driver is provided by a module. + $module = NULL; + $connection_class = NULL; + $url_components = parse_url($url); + if (isset($url_components['query'])) { + parse_str($url_components['query'], $query); + if ($query['module']) { + $module = $query['module']; + // Set up an additional autoloader. We don't use the main autoloader as + // this method can be called before Drupal is installed and is never + // called during regular runtime. + $namespace = "Drupal\\$module\\Driver\\Database\\$driver"; + $psr4_base_directory = Database::findDriverAutoloadDirectory($namespace, $root, TRUE); + $additional_class_loader = new ClassLoader(); + $additional_class_loader->addPsr4($namespace . '\\', $psr4_base_directory); + $additional_class_loader->register(TRUE); + $connection_class = $custom_connection_class = $namespace . '\\Connection'; } } - return $connection_class::createConnectionOptionsFromUrl($url, $root); + if (!$module) { + // Determine the connection class to use. Discover if the URL has a valid + // driver scheme. Try with Drupal 8 style custom drivers first, since + // those can override/extend the core ones. + $connection_class = $custom_connection_class = "Drupal\\Driver\\Database\\{$driver}\\Connection"; + if (!class_exists($connection_class)) { + // If the URL is not relative to a custom driver, try with core ones. + $connection_class = "Drupal\\Core\\Database\\Driver\\{$driver}\\Connection"; + } + } + + if (!class_exists($connection_class)) { + throw new \InvalidArgumentException("Can not convert '$url' to a database connection, class '$custom_connection_class' does not exist"); + } + + $options = $connection_class::createConnectionOptionsFromUrl($url, $root); + + // If the driver is provided by a module add the necessary information to + // autoload the code. + // @see \Drupal\Core\Site\Settings::initialize() + if (isset($psr4_base_directory)) { + $options['autoload'] = $psr4_base_directory; + } + + return $options; + } + + /** + * Finds the directory to add to the autoloader for the driver's namespace. + * + * For Drupal sites that manage their codebase with Composer, the package + * that provides the database driver should add the driver's namespace to + * Composer's autoloader. However, to support sites that add Drupal modules + * without Composer, and because the database connection must be established + * before Drupal adds the module's entire namespace to the autoloader, the + * database connection info array can include an "autoload" key containing + * the autoload directory for the driver's namespace. For requests that + * connect to the database via a connection info array, the value of the + * "autoload" key is automatically added to the autoloader. + * + * This method can be called to find the default value of that key when the + * database connection info array isn't available. This includes: + * - Console commands and test runners that connect to a database specified + * by a database URL rather than a connection info array. + * - During installation, prior to the connection info array being written to + * settings.php. + * + * This method returns the directory that must be added to the autoloader for + * the given namespace. + * - If the namespace is a sub-namespace of a Drupal module, then this method + * returns the autoload directory for that namespace, allowing Drupal + * modules containing database drivers to be added to a Drupal website + * without Composer. + * - If the namespace is a sub-namespace of Drupal\Core or Drupal\Driver, + * then this method returns FALSE, because Drupal core's autoloader already + * includes these namespaces, so no additional autoload directory is + * required for any code within them. + * - If the namespace is anything else, then this method returns FALSE, + * because neither drupal_get_database_types() nor + * static::convertDbUrlToConnectionInfo() support that anyway. One can + * manually edit the connection info array in settings.php to reference + * any arbitrary namespace, but requests using that would use the + * corresponding 'autoload' key in that connection info rather than calling + * this method. + * + * @param string $namespace + * The database driver's namespace. + * @param string $root + * The root directory of the Drupal installation. + * + * @return string|false + * The PSR-4 directory to add to the autoloader for the namespace if the + * namespace is a sub-namespace of a Drupal module. FALSE otherwise, as + * explained above. + * + * @throws \RuntimeException + * Exception thrown when a module provided database driver does not exist. + */ + public static function findDriverAutoloadDirectory($namespace, $root) { + // As explained by this method's documentation, return FALSE if the + // namespace is not a sub-namespace of a Drupal module. + if (!static::isWithinModuleNamespace($namespace)) { + return FALSE; + } + + // Extract the module information from the namespace. + [, $module, $module_relative_namespace] = explode('\\', $namespace, 3); + + // The namespace is within a Drupal module. Find the directory where the + // module is located. + $extension_discovery = new ExtensionDiscovery($root, FALSE, []); + $modules = $extension_discovery->scan('module'); + if (!isset($modules[$module])) { + throw new \RuntimeException(sprintf("Cannot find the module '%s' for the database driver namespace '%s'", $module, $namespace)); + } + $module_directory = $modules[$module]->getPath(); + + // All code within the Drupal\MODULE namespace is expected to follow a + // PSR-4 layout within the module's "src" directory. + $driver_directory = $module_directory . '/src/' . str_replace('\\', '/', $module_relative_namespace) . '/'; + if (!is_dir($root . '/' . $driver_directory)) { + throw new \RuntimeException(sprintf("Cannot find the database driver namespace '%s' in module '%s'", $namespace, $module)); + } + return $driver_directory; } /** @@ -488,7 +605,16 @@ abstract class Database { if (empty($db_info) || empty($db_info['default'])) { throw new \RuntimeException("Database connection $key not defined or missing the 'default' settings"); } - $connection_class = static::getDatabaseDriverNamespace($db_info['default']) . '\\Connection'; + $namespace = static::getDatabaseDriverNamespace($db_info['default']); + + // If the driver namespace is within a Drupal module, add the module name + // to the connection options to make it easy for the connection class's + // createUrlFromConnectionOptions() method to add it to the URL. + if (static::isWithinModuleNamespace($namespace)) { + $db_info['default']['module'] = explode('\\', $namespace)[1]; + } + + $connection_class = $namespace . '\\Connection'; return $connection_class::createUrlFromConnectionOptions($db_info['default']); } @@ -511,4 +637,32 @@ abstract class Database { return 'Drupal\\Core\\Database\\Driver\\' . $connection_info['driver']; } + /** + * Checks whether a namespace is within the namespace of a Drupal module. + * + * This can be used to determine if a database driver's namespace is provided + * by a Drupal module. + * + * @param string $namespace + * The namespace (for example, of a database driver) to check. + * + * @return bool + * TRUE if the passed in namespace is a sub-namespace of a Drupal module's + * namespace. + * + * @todo https://www.drupal.org/project/drupal/issues/3125476 Remove if we + * add this to the extension API or if + * \Drupal\Core\Database\Database::getConnectionInfoAsUrl() is removed. + */ + private static function isWithinModuleNamespace(string $namespace) { + [$first, $second] = explode('\\', $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); + } + } diff --git a/core/lib/Drupal/Core/Database/Install/Tasks.php b/core/lib/Drupal/Core/Database/Install/Tasks.php index 6ba3ba733c6..2313021d195 100644 --- a/core/lib/Drupal/Core/Database/Install/Tasks.php +++ b/core/lib/Drupal/Core/Database/Install/Tasks.php @@ -202,6 +202,13 @@ abstract class Tasks { * The options form array. */ public function getFormOptions(array $database) { + // Use reflection to determine the driver name. + // @todo https:///www.drupal.org/node/3123240 Provide a better way to get + // the driver name. + $reflection = new \ReflectionClass($this); + $dir_parts = explode(DIRECTORY_SEPARATOR, dirname(dirname($reflection->getFileName()))); + $driver = array_pop($dir_parts); + $form['database'] = [ '#type' => 'textfield', '#title' => t('Database name'), @@ -210,7 +217,7 @@ abstract class Tasks { '#required' => TRUE, '#states' => [ 'required' => [ - ':input[name=driver]' => ['value' => $this->pdoDriver], + ':input[name=driver]' => ['value' => $driver], ], ], ]; @@ -223,7 +230,7 @@ abstract class Tasks { '#required' => TRUE, '#states' => [ 'required' => [ - ':input[name=driver]' => ['value' => $this->pdoDriver], + ':input[name=driver]' => ['value' => $driver], ], ], ]; diff --git a/core/lib/Drupal/Core/Installer/Form/SiteSettingsForm.php b/core/lib/Drupal/Core/Installer/Form/SiteSettingsForm.php index 75099a2a354..8464519573a 100644 --- a/core/lib/Drupal/Core/Installer/Form/SiteSettingsForm.php +++ b/core/lib/Drupal/Core/Installer/Form/SiteSettingsForm.php @@ -160,6 +160,10 @@ class SiteSettingsForm extends FormBase { // Cut the trailing \Install from namespace. $database['namespace'] = substr($install_namespace, 0, strrpos($install_namespace, '\\')); $database['driver'] = $driver; + // See default.settings.php for an explanation of the 'autoload' key. + if ($autoload = Database::findDriverAutoloadDirectory($database['namespace'], DRUPAL_ROOT)) { + $database['autoload'] = $autoload; + } $form_state->set('database', $database); foreach ($this->getDatabaseErrors($database, $form_state->getValue('settings_file')) as $name => $message) { diff --git a/core/lib/Drupal/Core/Site/Settings.php b/core/lib/Drupal/Core/Site/Settings.php index ad8713a7524..a9ac950e16a 100644 --- a/core/lib/Drupal/Core/Site/Settings.php +++ b/core/lib/Drupal/Core/Site/Settings.php @@ -125,8 +125,21 @@ final class Settings { require $app_root . '/' . $site_path . '/settings.php'; } - // Initialize Database. - Database::setMultipleConnectionInfo($databases); + // Initialize databases. + foreach ($databases as $key => $targets) { + foreach ($targets as $target => $info) { + Database::addConnectionInfo($key, $target, $info); + // If the database driver is provided by a module, then its code may + // need to be instantiated prior to when the module's root namespace + // is added to the autoloader, because that happens during service + // container initialization but the container definition is likely in + // the database. Therefore, allow the connection info to specify an + // autoload directory for the driver. + if (isset($info['autoload'])) { + $class_loader->addPsr4($info['namespace'] . '\\', $info['autoload']); + } + } + } // For BC ensure the $config_directories global is set both in the global // and settings. diff --git a/core/modules/system/tests/modules/database_statement_monitoring_test/src/mysql/Install/Tasks.php b/core/modules/system/tests/modules/database_statement_monitoring_test/src/mysql/Install/Tasks.php new file mode 100644 index 00000000000..443072d4745 --- /dev/null +++ b/core/modules/system/tests/modules/database_statement_monitoring_test/src/mysql/Install/Tasks.php @@ -0,0 +1,8 @@ +driver(); + if (!in_array($driver, ['mysql', 'pgsql'])) { + $this->markTestSkipped("This test does not support the {$driver} database driver."); + } + $this->testDriverName = 'Drivertest' . ucfirst($driver); + + // Assert that we are using the database drivers from the driver_test module. + $elements = $this->xpath('//label[@for="edit-driver-drivertestmysql"]'); + $this->assertEqual(current($elements)->getText(), 'MySQL by the driver_test module'); + $elements = $this->xpath('//label[@for="edit-driver-drivertestpgsql"]'); + $this->assertEqual(current($elements)->getText(), 'PostgreSQL by the driver_test module'); + + $settings = $this->parameters['forms']['install_settings_form']; + + $settings['driver'] = $this->testDriverName; + $settings[$this->testDriverName] = $settings[$driver]; + unset($settings[$driver]); + $edit = $this->translatePostValues($settings); + $this->drupalPostForm(NULL, $edit, $this->translations['Save and continue']); + } + + /** + * Confirms that the installation succeeded. + */ + public function testInstalled() { + $this->assertUrl('user/1'); + $this->assertResponse(200); + + // Assert that in the settings.php the database connection array has the + // correct values set. + $contents = file_get_contents($this->root . '/' . $this->siteDirectory . '/settings.php'); + $this->assertContains("'namespace' => 'Drupal\\\\driver_test\\\\Driver\\\\Database\\\\{$this->testDriverName}',", $contents); + $this->assertContains("'driver' => '{$this->testDriverName}',", $contents); + $this->assertContains("'autoload' => 'core/modules/system/tests/modules/driver_test/src/Driver/Database/{$this->testDriverName}/',", $contents); + } + +} diff --git a/core/tests/Drupal/FunctionalTests/Installer/InstallerTest.php b/core/tests/Drupal/FunctionalTests/Installer/InstallerTest.php index 1fd54494825..6069d2fcbeb 100644 --- a/core/tests/Drupal/FunctionalTests/Installer/InstallerTest.php +++ b/core/tests/Drupal/FunctionalTests/Installer/InstallerTest.php @@ -75,6 +75,13 @@ class InstallerTest extends InstallerTestBase { // Assert that the expected title is present. $this->assertEqual('Database configuration', $this->cssSelect('main h2')[0]->getText()); + // Assert that we use the by core supported database drivers by default and + // not the ones from the driver_test module. + $elements = $this->xpath('//label[@for="edit-driver-mysql"]'); + $this->assertEqual(current($elements)->getText(), 'MySQL, MariaDB, Percona Server, or equivalent'); + $elements = $this->xpath('//label[@for="edit-driver-pgsql"]'); + $this->assertEqual(current($elements)->getText(), 'PostgreSQL'); + parent::setUpSettings(); } diff --git a/core/tests/Drupal/FunctionalTests/Update/UpdatePathTestBase.php b/core/tests/Drupal/FunctionalTests/Update/UpdatePathTestBase.php index 89835ece0cf..d0a4644ce07 100644 --- a/core/tests/Drupal/FunctionalTests/Update/UpdatePathTestBase.php +++ b/core/tests/Drupal/FunctionalTests/Update/UpdatePathTestBase.php @@ -312,8 +312,10 @@ abstract class UpdatePathTestBase extends BrowserTestBase { \Drupal::setContainer($container); require_once __DIR__ . '/../../../../includes/install.inc'; - $connection = Database::getConnection(); - $errors = db_installer_object($connection->driver())->runTasks(); + $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/KernelTestBase.php b/core/tests/Drupal/KernelTests/KernelTestBase.php index 180f6e56311..999c5c6cb6e 100644 --- a/core/tests/Drupal/KernelTests/KernelTestBase.php +++ b/core/tests/Drupal/KernelTests/KernelTestBase.php @@ -366,8 +366,10 @@ abstract class KernelTestBase extends TestCase implements ServiceProviderInterfa // Ensure database tasks have been run. require_once __DIR__ . '/../../../includes/install.inc'; - $connection = Database::getConnection(); - $errors = db_installer_object($connection->driver())->runTasks(); + $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/Tests/Core/Database/DatabaseTest.php b/core/tests/Drupal/Tests/Core/Database/DatabaseTest.php new file mode 100644 index 00000000000..9f01555a321 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Database/DatabaseTest.php @@ -0,0 +1,123 @@ +additionalClassloader = new ClassLoader(); + $this->additionalClassloader->register(); + // Mock the container so we don't need to mock drupal_valid_test_ua(). + // @see \Drupal\Core\Extension\ExtensionDiscovery::scan() + $this->root = dirname(dirname(dirname(dirname(dirname(dirname(__DIR__)))))); + $container = $this->createMock(ContainerInterface::class); + $container->expects($this->any()) + ->method('has') + ->with('kernel') + ->willReturn(TRUE); + $container->expects($this->any()) + ->method('get') + ->with('site.path') + ->willReturn(''); + \Drupal::setContainer($container); + } + + /** + * @covers ::findDriverAutoloadDirectory + * @dataProvider providerFindDriverAutoloadDirectory + */ + public function testFindDriverAutoloadDirectory($expected, $namespace) { + new Settings(['extension_discovery_scan_tests' => TRUE]); + // The only module that provides a driver in core is a test module. + $this->assertSame($expected, Database::findDriverAutoloadDirectory($namespace, $this->root)); + } + + /** + * Data provider for ::testFindDriverAutoloadDirectory(). + * + * @return array + */ + public function providerFindDriverAutoloadDirectory() { + return [ + 'core mysql' => [FALSE, 'Drupal\Core\Database\Driver\mysql'], + 'D8 custom fake' => [FALSE, 'Drupal\Driver\Database\corefake'], + 'module mysql' => ['core/modules/system/tests/modules/driver_test/src/Driver/Database/DrivertestMysql/', 'Drupal\driver_test\Driver\Database\DrivertestMysql'], + ]; + } + + /** + * @covers ::findDriverAutoloadDirectory + * @dataProvider providerFindDriverAutoloadDirectoryException + */ + public function testFindDriverAutoloadDirectoryException($expected_message, $namespace, $include_tests) { + new Settings(['extension_discovery_scan_tests' => $include_tests]); + if ($include_tests === FALSE) { + // \Drupal\Core\Extension\ExtensionDiscovery::scan() needs + // drupal_valid_test_ua(). + include $this->root . '/core/includes/bootstrap.inc'; + } + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($expected_message); + Database::findDriverAutoloadDirectory($namespace, $this->root); + } + + /** + * Data provider for ::testFindDriverAutoloadDirectoryException(). + * + * @return array + */ + public function providerFindDriverAutoloadDirectoryException() { + return [ + 'test module but tests not included' => ["Cannot find the module 'driver_test' for the database driver namespace 'Drupal\driver_test\Driver\Database\DrivertestMysql'", 'Drupal\driver_test\Driver\Database\DrivertestMysql', FALSE], + 'non-existent driver in test module' => ["Cannot find the database driver namespace 'Drupal\driver_test\Driver\Database\sqlite' in module 'driver_test'", 'Drupal\driver_test\Driver\Database\sqlite', TRUE], + 'non-existent module' => ["Cannot find the module 'does_not_exist' for the database driver namespace 'Drupal\does_not_exist\Driver\Database\mysql'", 'Drupal\does_not_exist\Driver\Database\mysql', TRUE], + ]; + } + + /** + * Adds a database driver that uses the D8's Drupal\Driver\Database namespace. + */ + protected function addD8CustomDrivers() { + $this->additionalClassloader->addPsr4("Drupal\\Driver\\Database\\corefake\\", __DIR__ . "/../../../../../tests/fixtures/database_drivers/custom/corefake"); + } + + /** + * Adds database drivers that are provided by modules. + */ + protected function addModuleDrivers() { + $this->additionalClassloader->addPsr4("Drupal\\driver_test\\Driver\\Database\\DrivertestMysql\\", __DIR__ . "/../../../../../modules/system/tests/modules/driver_test/src/Driver/Database/DrivertestMysql"); + $this->additionalClassloader->addPsr4("Drupal\\corefake\\Driver\\Database\\corefake\\", __DIR__ . "/../../../../../tests/fixtures/database_drivers/module/corefake/src/Driver/Database/corefake"); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Database/InstallerObjectTest.php b/core/tests/Drupal/Tests/Core/Database/InstallerObjectTest.php index 87cf3208a86..08ee33c7060 100644 --- a/core/tests/Drupal/Tests/Core/Database/InstallerObjectTest.php +++ b/core/tests/Drupal/Tests/Core/Database/InstallerObjectTest.php @@ -6,6 +6,7 @@ use Composer\Autoload\ClassLoader; use Drupal\Core\Database\Driver\mysql\Install\Tasks as MysqlInstallTasks; use Drupal\Driver\Database\fake\Install\Tasks as FakeInstallTasks; use Drupal\Driver\Database\corefake\Install\Tasks as CustomCoreFakeInstallTasks; +use Drupal\driver_test\Driver\Database\DrivertestMysql\Install\Tasks as DriverTestMysqlInstallTasks; use Drupal\Tests\UnitTestCase; /** @@ -33,14 +34,15 @@ class InstallerObjectTest extends UnitTestCase { $additional_class_loader->addPsr4("Drupal\\Driver\\Database\\fake\\", __DIR__ . "/../../../../../tests/fixtures/database_drivers/custom/fake"); $additional_class_loader->addPsr4("Drupal\\Core\\Database\\Driver\\corefake\\", __DIR__ . "/../../../../../tests/fixtures/database_drivers/core/corefake"); $additional_class_loader->addPsr4("Drupal\\Driver\\Database\\corefake\\", __DIR__ . "/../../../../../tests/fixtures/database_drivers/custom/corefake"); + $additional_class_loader->addPsr4("Drupal\\driver_test\\Driver\\Database\\DrivertestMysql\\", __DIR__ . "/../../../../../../modules/system/tests/modules/driver_test/src/Driver/Database/DrivertestMysql"); $additional_class_loader->register(TRUE); } /** * @dataProvider providerDbInstallerObject */ - public function testDbInstallerObject($driver, $expected_class_name) { - $object = db_installer_object($driver); + public function testDbInstallerObject($driver, $namespace, $expected_class_name) { + $object = db_installer_object($driver, $namespace); $this->assertEquals(get_class($object), $expected_class_name); } @@ -50,18 +52,22 @@ class InstallerObjectTest extends UnitTestCase { * @return array * Array of arrays with the following elements: * - driver: The driver name. + * - namespace: The namespace providing the driver. * - class: The fully qualified class name of the expected install task. */ public function providerDbInstallerObject() { return [ // A driver only in the core namespace. - ['mysql', MysqlInstallTasks::class], + ['mysql', NULL, MysqlInstallTasks::class], // A driver only in the custom namespace. - ['fake', FakeInstallTasks::class], + ['fake', "Drupal\\Driver\\Database\\fake", FakeInstallTasks::class], // A driver in both namespaces. The custom one takes precedence. - ['corefake', CustomCoreFakeInstallTasks::class], + ['corefake', "Drupal\\Driver\\Database\\corefake", CustomCoreFakeInstallTasks::class], + + // A driver from a module that has a different name as the driver. + ['DrivertestMysql', "Drupal\\driver_test\\Driver\\Database\\DrivertestMysql", DriverTestMysqlInstallTasks::class], ]; } diff --git a/core/tests/Drupal/Tests/Core/Database/UrlConversionTest.php b/core/tests/Drupal/Tests/Core/Database/UrlConversionTest.php index 8f9b9705b04..bc40d896e14 100644 --- a/core/tests/Drupal/Tests/Core/Database/UrlConversionTest.php +++ b/core/tests/Drupal/Tests/Core/Database/UrlConversionTest.php @@ -2,8 +2,8 @@ namespace Drupal\Tests\Core\Database; -use Composer\Autoload\ClassLoader; use Drupal\Core\Database\Database; +use Drupal\Core\Site\Settings; use Drupal\Tests\UnitTestCase; /** @@ -26,11 +26,21 @@ class UrlConversionTest extends UnitTestCase { */ protected function setUp() { parent::setUp(); - $additional_class_loader = new ClassLoader(); - $additional_class_loader->addPsr4("Drupal\\Driver\\Database\\fake\\", __DIR__ . "/../../../../../tests/fixtures/database_drivers/custom/fake"); - $additional_class_loader->addPsr4("Drupal\\Core\\Database\\Driver\\corefake\\", __DIR__ . "/../../../../../tests/fixtures/database_drivers/core/corefake"); - $additional_class_loader->addPsr4("Drupal\\Driver\\Database\\corefake\\", __DIR__ . "/../../../../../tests/fixtures/database_drivers/custom/corefake"); - $additional_class_loader->register(TRUE); + $this->root = dirname(dirname(dirname(dirname(dirname(dirname(dirname(__FILE__))))))); + // Mock the container so we don't need to mock drupal_valid_test_ua(). + // @see \Drupal\Core\Extension\ExtensionDiscovery::scan() + $container = $this->createMock('Symfony\Component\DependencyInjection\ContainerInterface'); + $container->expects($this->any()) + ->method('has') + ->with('kernel') + ->willReturn(TRUE); + $container->expects($this->any()) + ->method('get') + ->with('site.path') + ->willReturn(''); + \Drupal::setContainer($container); + + new Settings(['extension_discovery_scan_tests' => TRUE]); } /** @@ -39,7 +49,7 @@ class UrlConversionTest extends UnitTestCase { * @dataProvider providerConvertDbUrlToConnectionInfo */ public function testDbUrltoConnectionConversion($root, $url, $database_array) { - $result = Database::convertDbUrlToConnectionInfo($url, $root); + $result = Database::convertDbUrlToConnectionInfo($url, $root ?: $this->root); $this->assertEquals($database_array, $result); } @@ -116,30 +126,66 @@ class UrlConversionTest extends UnitTestCase { 'namespace' => 'Drupal\Core\Database\Driver\sqlite', ], ], - 'Fake custom database driver, without prefix' => [ + 'MySQL contrib test driver without prefix' => [ '', - 'fake://fake_user:fake_pass@fake_host:3456/fake_database', + 'DrivertestMysql://test_user:test_pass@test_host:3306/test_database?module=driver_test', [ - 'driver' => 'fake', - 'username' => 'fake_user', - 'password' => 'fake_pass', - 'host' => 'fake_host', - 'database' => 'fake_database', - 'port' => 3456, - 'namespace' => 'Drupal\Driver\Database\fake', + 'driver' => 'DrivertestMysql', + 'username' => 'test_user', + 'password' => 'test_pass', + 'host' => 'test_host', + 'database' => 'test_database', + 'port' => 3306, + 'namespace' => 'Drupal\driver_test\Driver\Database\DrivertestMysql', + 'autoload' => 'core/modules/system/tests/modules/driver_test/src/Driver/Database/DrivertestMysql/', ], ], - 'Fake core driver with custom override, without prefix' => [ + 'MySQL contrib test driver with prefix' => [ '', - 'corefake://fake_user:fake_pass@fake_host:3456/fake_database', + 'DrivertestMysql://test_user:test_pass@test_host:3306/test_database?module=driver_test#bar', [ - 'driver' => 'corefake', - 'username' => 'fake_user', - 'password' => 'fake_pass', - 'host' => 'fake_host', - 'database' => 'fake_database', - 'port' => 3456, - 'namespace' => 'Drupal\Driver\Database\corefake', + 'driver' => 'DrivertestMysql', + 'username' => 'test_user', + 'password' => 'test_pass', + 'host' => 'test_host', + 'database' => 'test_database', + 'prefix' => [ + 'default' => 'bar', + ], + 'port' => 3306, + 'namespace' => 'Drupal\driver_test\Driver\Database\DrivertestMysql', + 'autoload' => 'core/modules/system/tests/modules/driver_test/src/Driver/Database/DrivertestMysql/', + ], + ], + 'PostgreSQL contrib test driver without prefix' => [ + '', + 'DrivertestPgsql://test_user:test_pass@test_host:5432/test_database?module=driver_test', + [ + 'driver' => 'DrivertestPgsql', + 'username' => 'test_user', + 'password' => 'test_pass', + 'host' => 'test_host', + 'database' => 'test_database', + 'port' => 5432, + 'namespace' => 'Drupal\driver_test\Driver\Database\DrivertestPgsql', + 'autoload' => 'core/modules/system/tests/modules/driver_test/src/Driver/Database/DrivertestPgsql/', + ], + ], + 'PostgreSQL contrib test driver with prefix' => [ + '', + 'DrivertestPgsql://test_user:test_pass@test_host:5432/test_database?module=driver_test#bar', + [ + 'driver' => 'DrivertestPgsql', + 'username' => 'test_user', + 'password' => 'test_pass', + 'host' => 'test_host', + 'database' => 'test_database', + 'prefix' => [ + 'default' => 'bar', + ], + 'port' => 5432, + 'namespace' => 'Drupal\driver_test\Driver\Database\DrivertestPgsql', + 'autoload' => 'core/modules/system/tests/modules/driver_test/src/Driver/Database/DrivertestPgsql/', ], ], ]; @@ -233,11 +279,67 @@ class UrlConversionTest extends UnitTestCase { ]; $expected_url4 = 'sqlite://localhost/test_database#pre'; + $info5 = [ + 'database' => 'test_database', + 'username' => 'test_user', + 'password' => 'test_pass', + 'prefix' => '', + 'host' => 'test_host', + 'port' => '3306', + 'driver' => 'DrivertestMysql', + 'namespace' => 'Drupal\\driver_test\\Driver\\Database\\DrivertestMysql', + 'autoload' => 'core/modules/system/tests/modules/driver_test/src/Driver/Database/DrivertestMysql/', + ]; + $expected_url5 = 'DrivertestMysql://test_user:test_pass@test_host:3306/test_database?module=driver_test'; + + $info6 = [ + 'database' => 'test_database', + 'username' => 'test_user', + 'password' => 'test_pass', + 'prefix' => 'pre', + 'host' => 'test_host', + 'port' => '3306', + 'driver' => 'DrivertestMysql', + 'namespace' => 'Drupal\\driver_test\\Driver\\Database\\DrivertestMysql', + 'autoload' => 'core/modules/system/tests/modules/driver_test/src/Driver/Database/DrivertestMysql/', + ]; + $expected_url6 = 'DrivertestMysql://test_user:test_pass@test_host:3306/test_database?module=driver_test#pre'; + + $info7 = [ + 'database' => 'test_database', + 'username' => 'test_user', + 'password' => 'test_pass', + 'prefix' => '', + 'host' => 'test_host', + 'port' => '5432', + 'driver' => 'DrivertestPgsql', + 'namespace' => 'Drupal\\driver_test\\Driver\\Database\\DrivertestPgsql', + 'autoload' => 'core/modules/system/tests/modules/driver_test/src/Driver/Database/drivertestpqsql/', + ]; + $expected_url7 = 'DrivertestPgsql://test_user:test_pass@test_host:5432/test_database?module=driver_test'; + + $info8 = [ + 'database' => 'test_database', + 'username' => 'test_user', + 'password' => 'test_pass', + 'prefix' => 'pre', + 'host' => 'test_host', + 'port' => '5432', + 'driver' => 'DrivertestPgsql', + 'namespace' => 'Drupal\\driver_test\\Driver\\Database\\DrivertestPgsql', + 'autoload' => 'core/modules/system/tests/modules/driver_test/src/Driver/Database/drivertestpqsql/', + ]; + $expected_url8 = 'DrivertestPgsql://test_user:test_pass@test_host:5432/test_database?module=driver_test#pre'; + return [ [$info1, $expected_url1], [$info2, $expected_url2], [$info3, $expected_url3], [$info4, $expected_url4], + [$info5, $expected_url5], + [$info6, $expected_url6], + [$info7, $expected_url7], + [$info8, $expected_url8], ]; } @@ -282,4 +384,24 @@ class UrlConversionTest extends UnitTestCase { ]; } + /** + * @covers ::convertDbUrlToConnectionInfo + */ + public function testDriverModuleDoesNotExist() { + $url = 'mysql://test_user:test_pass@test_host:3306/test_database?module=does_not_exist'; + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage("Cannot find the module 'does_not_exist' for the database driver namespace 'Drupal\does_not_exist\Driver\Database\mysql'"); + Database::convertDbUrlToConnectionInfo($url, $this->root); + } + + /** + * @covers ::convertDbUrlToConnectionInfo + */ + public function testModuleDriverDoesNotExist() { + $url = 'mysql://test_user:test_pass@test_host:3306/test_database?module=driver_test'; + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage("Cannot find the database driver namespace 'Drupal\driver_test\Driver\Database\mysql' in module 'driver_test'"); + Database::convertDbUrlToConnectionInfo($url, $this->root); + } + } diff --git a/core/tests/fixtures/database_drivers/module/corefake/src/Driver/Database/corefake/Connection.php b/core/tests/fixtures/database_drivers/module/corefake/src/Driver/Database/corefake/Connection.php new file mode 100644 index 00000000000..f009c84a582 --- /dev/null +++ b/core/tests/fixtures/database_drivers/module/corefake/src/Driver/Database/corefake/Connection.php @@ -0,0 +1,14 @@ + '/path/to/databasefilename', * ]; * @endcode + * + * Sample Database configuration format for a driver in a contributed module: + * @code + * $databases['default']['default'] = [ + * 'driver' => 'mydriver', + * 'namespace' => 'Drupal\mymodule\Driver\Database\mydriver', + * 'autoload' => 'modules/mymodule/src/Driver/Database/mydriver/', + * 'database' => 'databasename', + * 'username' => 'sqlusername', + * 'password' => 'sqlpassword', + * 'host' => 'localhost', + * 'prefix' => '', + * ]; + * @endcode */ /**