Commit ff6279aa authored by effulgentsia's avatar effulgentsia
Browse files

Issue #3120096 by alexpott, daffie, effulgentsia, Neslee Canil Pinto, xjm,...

Issue #3120096 by alexpott, daffie, effulgentsia, Neslee Canil Pinto, xjm, mondrake, catch, ravi.shankar: Support contrib database driver directories in a fixed location in a module
parent 363a6611
......@@ -105,6 +105,16 @@
* 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.
*
* For each database, you may optionally specify multiple "target" databases.
* A target database allows Drupal to try to send certain queries to a
* different database if it can but fall back to the default connection if not.
......@@ -216,6 +226,20 @@
* '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
*/
/**
......
......@@ -373,6 +373,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.
......@@ -1204,7 +1209,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;
}
......
......@@ -169,6 +169,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]);
......@@ -177,11 +179,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;
}
}
// 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 => $file) {
$installer = db_installer_object($driver);
foreach ($drivers as $driver => $namespace) {
$installer = db_installer_object($driver, $namespace);
if ($installer->installable()) {
$databases[$driver] = $installer;
}
......@@ -1082,20 +1116,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();
}
......
......@@ -1456,7 +1456,7 @@ abstract public function queryTemporary($query, array $args = [], array $options
* 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().
*
......@@ -1656,10 +1656,6 @@ public function __sleep() {
/**
* 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
......@@ -1673,6 +1669,10 @@ public function __sleep() {
* 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) {
......@@ -1718,12 +1718,10 @@ public static function createConnectionOptionsFromUrl($url, $root) {
/**
* 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.
......@@ -1732,6 +1730,10 @@ public static function createConnectionOptionsFromUrl($url, $root) {
* 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) {
......@@ -1758,6 +1760,11 @@ public static function createUrlFromConnectionOptions(array $connection_options)
$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'];
}
......
......@@ -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 @@ public static function ignoreTarget($key, $target) {
* @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 @@ public static function convertDbUrlToConnectionInfo($url, $root) {
}
$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";
// 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';
}
}
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)) {
throw new \InvalidArgumentException("Can not convert '$url' to a database connection, class '$custom_connection_class' does not exist");
// If the URL is not relative to a custom driver, try with core ones.
$connection_class = "Drupal\\Core\\Database\\Driver\\{$driver}\\Connection";
}
}
return $connection_class::createConnectionOptionsFromUrl($url, $root);
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 @@ public static function getConnectionInfoAsUrl($key = 'default') {
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 @@ protected static function getDatabaseDriverNamespace(array $connection_info) {
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);
}
}
......@@ -218,6 +218,13 @@ protected function checkEngineVersion() {
* 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'),
......@@ -226,7 +233,7 @@ public function getFormOptions(array $database) {
'#required' => TRUE,
'#states' => [
'required' => [
':input[name=driver]' => ['value' => $this->pdoDriver],
':input[name=driver]' => ['value' => $driver],
],
],
];
......@@ -239,7 +246,7 @@ public function getFormOptions(array $database) {
'#required' => TRUE,
'#states' => [
'required' => [
':input[name=driver]' => ['value' => $this->pdoDriver],
':input[name=driver]' => ['value' => $driver],
],
],
];
......
......@@ -160,6 +160,10 @@ public function validateForm(array &$form, FormStateInterface $form_state) {
// 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) {
......
......@@ -122,8 +122,21 @@ public static function initialize($app_root, $site_path, &$class_loader) {
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']);
}
}
}
// Initialize Settings.
new Settings($settings);
......
<?php
namespace Drupal\database_statement_monitoring_test\mysql\Install;
use Drupal\Core\Database\Driver\mysql\Install\Tasks as BaseTasks;
class Tasks extends BaseTasks {
}
<?php
namespace Drupal\database_statement_monitoring_test\pgsql\Install;
use Drupal\Core\Database\Driver\pgsql\Install\Tasks as BaseTasks;
class Tasks extends BaseTasks {
}
<?php
namespace Drupal\database_statement_monitoring_test\sqlite\Install;
use Drupal\Core\Database\Driver\sqlite\Install\Tasks as BaseTasks;
class Tasks extends BaseTasks {
}
name: 'Contrib database driver test'
type: module
description: 'Support database contrib driver testing.'
package: Testing
version: VERSION
<?php
namespace Drupal\driver_test\Driver\Database\DrivertestMysql;
use Drupal\Core\Database\Driver\mysql\Connection as CoreConnection;
/**
* MySQL test implementation of \Drupal\Core\Database\Connection.
*/
class Connection extends CoreConnection {
/**
* {@inheritdoc}
*/
public function driver() {
return 'DrivertestMysql';
}
}
<?php
namespace Drupal\driver_test\Driver\Database\DrivertestMysql;
use Drupal\Core\Database\Driver\mysql\Delete as CoreDelete;
/**
* MySQL test implementation of \Drupal\Core\Database\Query\Delete.
*/
class Delete extends CoreDelete {}
<?php
namespace Drupal\driver_test\Driver\Database\DrivertestMysql;
use Drupal\Core\Database\Driver\mysql\Insert as CoreInsert;
/**
* MySQL test implementation of \Drupal\Core\Database\Query\Insert.
*/
class Insert extends CoreInsert {}
<?php
namespace Drupal\driver_test\Driver\Database\DrivertestMysql\Install;
use Drupal\Core\Database\Driver\mysql\Install\Tasks as CoreTasks;
/**
* Specifies installation tasks for MySQL test databases.
*/
class Tasks extends CoreTasks {