diff --git a/core/lib/Drupal/Core/Database/Connection.php b/core/lib/Drupal/Core/Database/Connection.php index 100ef6869c..3ae2ef2910 100644 --- a/core/lib/Drupal/Core/Database/Connection.php +++ b/core/lib/Drupal/Core/Database/Connection.php @@ -1472,4 +1472,116 @@ public function __sleep() { throw new \LogicException('The database connection is not serializable. This probably means you are serializing an object that has an indirect reference to the database connection. Adjust your code so that is not necessary. Alternatively, look at DependencySerializationTrait as a temporary solution.'); } + /** + * Converts a URL to a database connection info array. + * + * @internal + * This method should not be called. Use + * \Drupal\Core\Database\Database::convertDbUrlToConnectionInfo() instead. + * + * @param string $url + * The URL. + * @param string $root + * The root directory of the Drupal installation. Some database drivers, + * like for example SQLite, need this information. + * + * @return array + * The connection options. + * + * @throws \InvalidArgumentException + * Exception thrown when the provided URL does not meet the minimum + * requirements. + * + * @see \Drupal\Core\Database\Database::convertDbUrlToConnectionInfo() + */ + public static function getOptionsFromUrl($url, $root) { + $url_components = parse_url($url); + if (!isset($url_components['scheme'], $url_components['host'], $url_components['path'])) { + throw new \InvalidArgumentException('Minimum requirement: driver://host/database'); + } + + $url_components += [ + 'user' => '', + 'pass' => '', + 'fragment' => '', + ]; + + // Remove leading slash from the URL path. + if ($url_components['path'][0] === '/') { + $url_components['path'] = substr($url_components['path'], 1); + } + + // Use reflection to get the namespace of the class being called. + $reflector = new ReflectionClass(get_called_class()); + + $database = [ + 'driver' => $url_components['scheme'], + 'username' => $url_components['user'], + 'password' => $url_components['pass'], + 'host' => $url_components['host'], + 'database' => $url_components['path'], + 'namespace' => $reflector->getNamespaceName(), + ]; + + if (isset($url_components['port'])) { + $database['port'] = $url_components['port']; + } + + if (!empty($url_components['fragment'])) { + $database['prefix']['default'] = $url_components['fragment']; + } + + return $database; + } + + /** + * Gets database connection as a URL. + * + * @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. + * + * @return string + * The connection info as a URL. + * + * @throws \InvalidArgumentException + * Exception thrown when the provided array of connection options does not + * meet the minimum requirements. + * + * @see \Drupal\Core\Database\Database::getConnectionInfoAsUrl() + */ + public static function getUrlFromOptions(array $connection_options) { + if (!isset($connection_options['driver'], $connection_options['database'])) { + throw new \InvalidArgumentException("As a minimum, the connection options array must contain at least the 'driver' and 'database' keys"); + } + + $user = ''; + if (isset($connection_options['username'])) { + $user = $connection_options['username']; + if (isset($connection_options['password'])) { + $user .= ':' . $connection_options['password']; + } + $user .= '@'; + } + + $host = empty($connection_options['host']) ? 'localhost' : $connection_options['host']; + + $db_url = $connection_options['driver'] . '://' . $user . $host; + + if (isset($connection_options['port'])) { + $db_url .= ':' . $connection_options['port']; + } + + $db_url .= '/' . $connection_options['database']; + + if (isset($connection_options['prefix']['default']) && $connection_options['prefix']['default'] !== '') { + $db_url .= '#' . $connection_options['prefix']['default']; + } + + return $db_url; + } + } diff --git a/core/lib/Drupal/Core/Database/Database.php b/core/lib/Drupal/Core/Database/Database.php index dd19018828..11781d8fda 100644 --- a/core/lib/Drupal/Core/Database/Database.php +++ b/core/lib/Drupal/Core/Database/Database.php @@ -365,13 +365,8 @@ throw new DriverNotSpecifiedException('Driver not specified for this database connection: ' . $key); } - if (!empty(self::$databaseInfo[$key][$target]['namespace'])) { - $driver_class = self::$databaseInfo[$key][$target]['namespace'] . '\\Connection'; - } - else { - // Fallback for Drupal 7 settings.php. - $driver_class = "Drupal\\Core\\Database\\Driver\\{$driver}\\Connection"; - } + $namespace = static::getDatabaseDriverNamespace(self::$databaseInfo[$key][$target]); + $driver_class = $namespace . '\\Connection'; $pdo_connection = $driver_class::open(self::$databaseInfo[$key][$target]); $new_connection = new $driver_class($pdo_connection, self::$databaseInfo[$key][$target]); @@ -455,36 +450,25 @@ public static function ignoreTarget($key, $target) { * requirements. */ public static function convertDbUrlToConnectionInfo($url, $root) { - $info = parse_url($url); - if (!isset($info['scheme'], $info['host'], $info['path'])) { - throw new \InvalidArgumentException('Minimum requirement: driver://host/database'); + // Check that the URL is well formed, starting with 'scheme://', where + // 'scheme' is a database driver name. + if (preg_match('/^(.*):\/\//', $url, $matches) !== 1) { + throw new \InvalidArgumentException("Missing scheme in URL '$url'"); } - $info += [ - 'user' => '', - 'pass' => '', - 'fragment' => '', - ]; - - // A SQLite database path with two leading slashes indicates a system path. - // Otherwise the path is relative to the Drupal root. - if ($info['path'][0] === '/') { - $info['path'] = substr($info['path'], 1); - } - if ($info['scheme'] === 'sqlite' && $info['path'][0] !== '/') { - $info['path'] = $root . '/' . $info['path']; + $driver = $matches[1]; + + // Discover if the URL has a valid driver scheme. Try with core drivers + // first. + $connection_class = "Drupal\\Core\\Database\\Driver\\{$driver}\\Connection"; + if (!class_exists($connection_class)) { + // If the URL is not relative to a core driver, try with custom ones. + $connection_class = "Drupal\\Driver\\Database\\{$driver}\\Connection"; + if (!class_exists($connection_class)) { + throw new \InvalidArgumentException("Can not convert '$url' to a database connection, class '$connection_class' does not exist"); + } } - $database = [ - 'driver' => $info['scheme'], - 'username' => $info['user'], - 'password' => $info['pass'], - 'host' => $info['host'], - 'database' => $info['path'], - ]; - if (isset($info['port'])) { - $database['port'] = $info['port']; - } - return $database; + return $connection_class::getOptionsFromUrl($url, $root); } /** @@ -495,32 +479,36 @@ public static function convertDbUrlToConnectionInfo($url, $root) { * * @return string * The connection info as a URL. + * + * @throws \RuntimeException + * When the database connection is not defined. */ public static function getConnectionInfoAsUrl($key = 'default') { $db_info = static::getConnectionInfo($key); - if ($db_info['default']['driver'] == 'sqlite') { - $db_url = 'sqlite://localhost/' . $db_info['default']['database']; + if (empty($db_info) || empty($db_info['default'])) { + throw new \RuntimeException("Database connection $key not defined or missing the 'default' settings"); } - else { - $user = ''; - if ($db_info['default']['username']) { - $user = $db_info['default']['username']; - if ($db_info['default']['password']) { - $user .= ':' . $db_info['default']['password']; - } - $user .= '@'; - } + $connection_class = static::getDatabaseDriverNamespace($db_info['default']) . '\\Connection'; + return $connection_class::getUrlFromOptions($db_info['default']); + } - $db_url = $db_info['default']['driver'] . '://' . $user . $db_info['default']['host']; - if (isset($db_info['default']['port'])) { - $db_url .= ':' . $db_info['default']['port']; - } - $db_url .= '/' . $db_info['default']['database']; - } - if ($db_info['default']['prefix']['default']) { - $db_url .= '#' . $db_info['default']['prefix']['default']; + /** + * Gets the PHP namespace of a database driver from the connection info. + * + * @param array $connection_info + * The database connection information, as defined in settings.php. The + * structure of this array depends on the database driver it is connecting + * to. + * + * @return string + * The PHP namespace of the driver's database. + */ + protected static function getDatabaseDriverNamespace(array $connection_info) { + if (isset($connection_info['namespace'])) { + return $connection_info['namespace']; } - return $db_url; + // Fallback for Drupal 7 settings.php. + return 'Drupal\\Core\\Database\\Driver\\' . $connection_info['driver']; } } diff --git a/core/lib/Drupal/Core/Database/Driver/sqlite/Connection.php b/core/lib/Drupal/Core/Database/Driver/sqlite/Connection.php index bbf86681fb..935c912e30 100644 --- a/core/lib/Drupal/Core/Database/Driver/sqlite/Connection.php +++ b/core/lib/Drupal/Core/Database/Driver/sqlite/Connection.php @@ -435,4 +435,50 @@ public function getFullQualifiedTableName($table) { return $prefix . $table; } + /** + * {@inheritdoc} + */ + public static function getOptionsFromUrl($url, $root) { + $database = parent::getOptionsFromUrl($url, $root); + + // A SQLite database path with two leading slashes indicates a system path. + // Otherwise the path is relative to the Drupal root. + $url_components = parse_url($url); + if ($url_components['path'][0] === '/') { + $url_components['path'] = substr($url_components['path'], 1); + } + if ($url_components['path'][0] === '/') { + $database['database'] = $url_components['path']; + } + else { + $database['database'] = $root . '/' . $url_components['path']; + } + + // User credentials and system port are irrelevant for SQLite. + unset( + $database['username'], + $database['password'], + $database['port'] + ); + + return $database; + } + + /** + * {@inheritdoc} + */ + public static function getUrlFromOptions(array $connection_options) { + if (!isset($connection_options['driver'], $connection_options['database'])) { + throw new \InvalidArgumentException("As a minimum, the connection options array must contain at least the 'driver' and 'database' keys"); + } + + $db_url = 'sqlite://localhost/' . $connection_options['database']; + + if (isset($connection_options['prefix']['default']) && $connection_options['prefix']['default'] !== NULL && $connection_options['prefix']['default'] !== '') { + $db_url .= '#' . $connection_options['prefix']['default']; + } + + return $db_url; + } + } diff --git a/core/modules/system/tests/src/Kernel/Scripts/DbCommandBaseTest.php b/core/modules/system/tests/src/Kernel/Scripts/DbCommandBaseTest.php index f111d7b36f..3bee38d1ab 100644 --- a/core/modules/system/tests/src/Kernel/Scripts/DbCommandBaseTest.php +++ b/core/modules/system/tests/src/Kernel/Scripts/DbCommandBaseTest.php @@ -58,18 +58,16 @@ public function testSpecifyDatabaseDoesNotExist() { * Test supplying database connection as a url. */ public function testSpecifyDbUrl() { - $connection_info = Database::getConnectionInfo('default')['default']; - $command = new DbCommandBaseTester(); $command_tester = new CommandTester($command); $command_tester->execute([ - '-db-url' => $connection_info['driver'] . '://' . $connection_info['username'] . ':' . $connection_info['password'] . '@' . $connection_info['host'] . '/' . $connection_info['database'], + '-db-url' => Database::getConnectionInfoAsUrl(), ]); $this->assertEquals('db-tools', $command->getDatabaseConnection($command_tester->getInput())->getKey()); Database::removeConnection('db-tools'); $command_tester->execute([ - '--database-url' => $connection_info['driver'] . '://' . $connection_info['username'] . ':' . $connection_info['password'] . '@' . $connection_info['host'] . '/' . $connection_info['database'], + '--database-url' => Database::getConnectionInfoAsUrl(), ]); $this->assertEquals('db-tools', $command->getDatabaseConnection($command_tester->getInput())->getKey()); } @@ -91,9 +89,8 @@ public function testPrefix() { ]); $this->assertEquals('extra', $command->getDatabaseConnection($command_tester->getInput())->tablePrefix()); - $connection_info = Database::getConnectionInfo('default')['default']; $command_tester->execute([ - '-db-url' => $connection_info['driver'] . '://' . $connection_info['username'] . ':' . $connection_info['password'] . '@' . $connection_info['host'] . '/' . $connection_info['database'], + '-db-url' => Database::getConnectionInfoAsUrl(), '--prefix' => 'extra2', ]); $this->assertEquals('extra2', $command->getDatabaseConnection($command_tester->getInput())->tablePrefix()); diff --git a/core/tests/Drupal/KernelTests/KernelTestBase.php b/core/tests/Drupal/KernelTests/KernelTestBase.php index 021e38d12b..5e9bffc2dd 100644 --- a/core/tests/Drupal/KernelTests/KernelTestBase.php +++ b/core/tests/Drupal/KernelTests/KernelTestBase.php @@ -460,7 +460,7 @@ protected function getDatabaseConnectionInfo() { // 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' => $value['prefix']['default'] . $this->databasePrefix, + 'default' => $this->databasePrefix, ]; } } diff --git a/core/tests/Drupal/Tests/Core/Database/UrlConversionTest.php b/core/tests/Drupal/Tests/Core/Database/UrlConversionTest.php index c514b2e652..b413450185 100644 --- a/core/tests/Drupal/Tests/Core/Database/UrlConversionTest.php +++ b/core/tests/Drupal/Tests/Core/Database/UrlConversionTest.php @@ -6,8 +6,16 @@ use Drupal\Tests\UnitTestCase; /** + * Tests for database URL to/from database connection array coversions. + * + * These tests run in isolation since we don't want the database static to + * affect other tests. + * * @coversDefaultClass \Drupal\Core\Database\Database * + * @runTestsInSeparateProcesses + * @preserveGlobalState disabled + * * @group Database */ class UrlConversionTest extends UnitTestCase { @@ -32,30 +40,69 @@ public function testDbUrltoConnectionConversion($root, $url, $database_array) { * - database_array: An array containing the expected results. */ public function providerConvertDbUrlToConnectionInfo() { - // Some valid datasets. - $root1 = ''; - $url1 = 'mysql://test_user:test_pass@test_host:3306/test_database'; - $database_array1 = [ - 'driver' => 'mysql', - 'username' => 'test_user', - 'password' => 'test_pass', - 'host' => 'test_host', - 'database' => 'test_database', - 'port' => '3306', - ]; - $root2 = '/var/www/d8'; - $url2 = 'sqlite://test_user:test_pass@test_host:3306/test_database'; - $database_array2 = [ - 'driver' => 'sqlite', - 'username' => 'test_user', - 'password' => 'test_pass', - 'host' => 'test_host', - 'database' => $root2 . '/test_database', - 'port' => 3306, - ]; return [ - [$root1, $url1, $database_array1], - [$root2, $url2, $database_array2], + 'MySql without prefix' => [ + '', + 'mysql://test_user:test_pass@test_host:3306/test_database', + [ + 'driver' => 'mysql', + 'username' => 'test_user', + 'password' => 'test_pass', + 'host' => 'test_host', + 'database' => 'test_database', + 'port' => 3306, + 'namespace' => 'Drupal\Core\Database\Driver\mysql', + ], + ], + 'SQLite, relative to root, without prefix' => [ + '/var/www/d8', + 'sqlite://localhost/test_database', + [ + 'driver' => 'sqlite', + 'host' => 'localhost', + 'database' => '/var/www/d8/test_database', + 'namespace' => 'Drupal\Core\Database\Driver\sqlite', + ], + ], + 'MySql with prefix' => [ + '', + 'mysql://test_user:test_pass@test_host:3306/test_database#bar', + [ + 'driver' => 'mysql', + 'username' => 'test_user', + 'password' => 'test_pass', + 'host' => 'test_host', + 'database' => 'test_database', + 'prefix' => [ + 'default' => 'bar', + ], + 'port' => 3306, + 'namespace' => 'Drupal\Core\Database\Driver\mysql', + ], + ], + 'SQLite, relative to root, with prefix' => [ + '/var/www/d8', + 'sqlite://localhost/test_database#foo', + [ + 'driver' => 'sqlite', + 'host' => 'localhost', + 'database' => '/var/www/d8/test_database', + 'prefix' => [ + 'default' => 'foo', + ], + 'namespace' => 'Drupal\Core\Database\Driver\sqlite', + ], + ], + 'SQLite, absolute path, without prefix' => [ + '/var/www/d8', + 'sqlite://localhost//baz/test_database', + [ + 'driver' => 'sqlite', + 'host' => 'localhost', + 'database' => '/baz/test_database', + 'namespace' => 'Drupal\Core\Database\Driver\sqlite', + ], + ], ]; } @@ -64,8 +111,8 @@ public function providerConvertDbUrlToConnectionInfo() { * * @dataProvider providerInvalidArgumentsUrlConversion */ - public function testGetInvalidArgumentExceptionInUrlConversion($url, $root) { - $this->setExpectedException(\InvalidArgumentException::class); + public function testGetInvalidArgumentExceptionInUrlConversion($url, $root, $expected_exception_message) { + $this->setExpectedException(\InvalidArgumentException::class, $expected_exception_message); Database::convertDbUrlToConnectionInfo($url, $root); } @@ -76,32 +123,28 @@ public function testGetInvalidArgumentExceptionInUrlConversion($url, $root) { * Array of arrays with the following elements: * - An invalid Url string. * - Drupal root string. + * - The expected exception message. */ public function providerInvalidArgumentsUrlConversion() { return [ - ['foo', ''], - ['foo', 'bar'], - ['foo://', 'bar'], - ['foo://bar', 'baz'], - ['foo://bar:port', 'baz'], - ['foo/bar/baz', 'bar2'], - ['foo://bar:baz@test1', 'test2'], + ['foo', '', "Missing scheme in URL 'foo'"], + ['foo', 'bar', "Missing scheme in URL 'foo'"], + ['foo://', 'bar', "Can not convert 'foo://' to a database connection, class 'Drupal\\Driver\\Database\\foo\\Connection' does not exist"], + ['foo://bar', 'baz', "Can not convert 'foo://bar' to a database connection, class 'Drupal\\Driver\\Database\\foo\\Connection' does not exist"], + ['foo://bar:port', 'baz', "Can not convert 'foo://bar:port' to a database connection, class 'Drupal\\Driver\\Database\\foo\\Connection' does not exist"], + ['foo/bar/baz', 'bar2', "Missing scheme in URL 'foo/bar/baz'"], + ['foo://bar:baz@test1', 'test2', "Can not convert 'foo://bar:baz@test1' to a database connection, class 'Drupal\\Driver\\Database\\foo\\Connection' does not exist"], ]; } /** - * @covers ::convertDbUrlToConnectionInfo + * @covers ::getConnectionInfoAsUrl * * @dataProvider providerGetConnectionInfoAsUrl */ public function testGetConnectionInfoAsUrl(array $info, $expected_url) { - Database::addConnectionInfo('default', 'default', $info); $url = Database::getConnectionInfoAsUrl(); - - // Remove the connection to not pollute subsequent datasets being tested. - Database::removeConnection('default'); - $this->assertEquals($expected_url, $url); } @@ -122,7 +165,6 @@ public function providerGetConnectionInfoAsUrl() { 'prefix' => '', 'host' => 'test_host', 'port' => '3306', - 'namespace' => 'Drupal\\Core\\Database\\Driver\\mysql', 'driver' => 'mysql', ]; $expected_url1 = 'mysql://test_user:test_pass@test_host:3306/test_database'; @@ -144,10 +186,58 @@ public function providerGetConnectionInfoAsUrl() { ]; $expected_url3 = 'sqlite://localhost/test_database'; + $info4 = [ + 'database' => 'test_database', + 'driver' => 'sqlite', + 'prefix' => 'pre', + ]; + $expected_url4 = 'sqlite://localhost/test_database#pre'; + return [ [$info1, $expected_url1], [$info2, $expected_url2], [$info3, $expected_url3], + [$info4, $expected_url4], + ]; + } + + /** + * Test ::getConnectionInfoAsUrl() exception for invalid arguments. + * + * @covers ::getConnectionInfoAsUrl + * + * @param array $connection_options + * The database connection information. + * @param string $expected_exception_message + * The expected exception message. + * + * @dataProvider providerInvalidArgumentGetConnectionInfoAsUrl + */ + public function testGetInvalidArgumentGetConnectionInfoAsUrl(array $connection_options, $expected_exception_message) { + Database::addConnectionInfo('default', 'default', $connection_options); + $this->setExpectedException(\InvalidArgumentException::class, $expected_exception_message); + $url = Database::getConnectionInfoAsUrl(); + } + + /** + * Dataprovider for testGetInvalidArgumentGetConnectionInfoAsUrl(). + * + * @return array + * Array of arrays with the following elements: + * - An array mocking the database connection info. Possible keys are + * database, username, password, prefix, host, port, namespace and driver. + * - The expected exception message. + */ + public function providerInvalidArgumentGetConnectionInfoAsUrl() { + return [ + 'Missing database key' => [ + [ + 'driver' => 'sqlite', + 'host' => 'localhost', + 'namespace' => 'Drupal\Core\Database\Driver\sqlite', + ], + "As a minimum, the connection options array must contain at least the 'driver' and 'database' keys", + ], ]; }