From d380886b6291cd40caee6738ac189d52f1b6ebff Mon Sep 17 00:00:00 2001 From: "Alexander M. Turek" Date: Fri, 30 Dec 2022 11:50:41 +0100 Subject: [PATCH] Standalone DSN parser --- UPGRADE.md | 22 +++ docs/en/reference/configuration.rst | 63 +++++-- src/DriverManager.php | 215 ++---------------------- src/Exception/MalformedDsnException.php | 14 ++ src/Tools/DsnParser.php | 208 +++++++++++++++++++++++ tests/DriverManagerTest.php | 40 ++++- tests/Tools/DsnParserTest.php | 167 ++++++++++++++++++ 7 files changed, 517 insertions(+), 212 deletions(-) create mode 100644 src/Exception/MalformedDsnException.php create mode 100644 src/Tools/DsnParser.php create mode 100644 tests/Tools/DsnParserTest.php diff --git a/UPGRADE.md b/UPGRADE.md index a2b36587d13..37519aeddae 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -8,6 +8,28 @@ awareness about deprecated code. # Upgrade to 3.6 +## Deprecated the `url` connection parameter + +DBAL ships with a new and configurable DSN parser that can be used to parse a +database URL into connection parameters understood by `DriverManager`. + +### Before + +```php +$connection = DriverManager::getConnection( + ['url' => 'mysql://my-user:t0ps3cr3t@my-host/my-database'] +); +``` + +### After + +```php +$dsnParser = new DsnParser(['mysql' => 'pdo_mysql']); +$connection = DriverManager::getConnection( + $dsnParser->parse('mysql://my-user:t0ps3cr3t@my-host/my-database') +); +``` + ## Deprecated `Connection::PARAM_*_ARRAY` constants Use the corresponding constants on `ArrayParameterType` instead. Please be aware that diff --git a/docs/en/reference/configuration.rst b/docs/en/reference/configuration.rst index e8c9c345c9f..8018c8eea86 100644 --- a/docs/en/reference/configuration.rst +++ b/docs/en/reference/configuration.rst @@ -10,6 +10,8 @@ You can get a DBAL Connection through the .. code-block:: php 'mydb', @@ -18,18 +20,23 @@ You can get a DBAL Connection through the 'host' => 'localhost', 'driver' => 'pdo_mysql', ]; - $conn = \Doctrine\DBAL\DriverManager::getConnection($connectionParams); + $conn = DriverManager::getConnection($connectionParams); -Or, using the simpler URL form: +Alternatively, if you store your connection settings as a connection URL (DSN), +you can parse the URL to extract connection parameters for ``DriverManager``: .. code-block:: php 'mysql://user:secret@localhost/mydb', - ]; - $conn = \Doctrine\DBAL\DriverManager::getConnection($connectionParams); + $dsnParser = new DsnParser(); + $connectionParams = $dsnParser + ->parse('mysqli://user:secret@localhost/mydb'); + + $conn = DriverManager::getConnection($connectionParams); The ``DriverManager`` returns an instance of ``Doctrine\DBAL\Connection`` which is a wrapper around the @@ -42,7 +49,7 @@ Connecting using a URL ~~~~~~~~~~~~~~~~~~~~~~ The easiest way to specify commonly used connection parameters is -using a database URL. The scheme is used to specify a driver, the +using a database URL or DSN. The scheme is used to specify a driver, the user and password in the URL encode user and password for the connection, followed by the host and port parts (the "authority"). The path after the authority part represents the name of the @@ -90,14 +97,42 @@ database name:: pdo-sqlite:///:memory: -.. note:: +Using the DSN parser +^^^^^^^^^^^^^^^^^^^^ + +By default, the URL scheme of the parsed DSN has to match one of DBAL's driver +names. However, it might be that you have to deal with connection strings where +you don't have control over the used scheme, e.g. in a PaaS environment. In +order to make the parser understand which driver to use e.g. for ``mysql://`` +DSNs, you can configure the parser with a mapping table: + +.. code-block:: php + + 'mysqli', 'postgres' => 'pdo_pgsql']); + $connectionParams = $dsnParser + ->parse('mysql://user:secret@localhost/mydb'); + +The DSN parser returns the connection params back to you so you can add or +modify individual parameters before passing the params to the +``DriverManager``. For example, you can add a database name if its missing in +the DSN or hardcode one if the DSN is not allowed to configure the database +name. + +.. code-block:: php + + parse($myDsn); + $connectionParams['dbname'] ??= 'default_db'; - Any information extracted from the URL overwrites existing values - for the parameter in question, but the rest of the information - is merged together. You could, for example, have a URL without - the ``charset`` setting in the query string, and then add a - ``charset`` connection parameter next to ``url``, to provide a - default value in case the URL doesn't contain a charset value. + $conn = DriverManager::getConnection($connectionParams); Driver ~~~~~~ diff --git a/src/DriverManager.php b/src/DriverManager.php index 38c239fa68b..5a4ae99ae1e 100644 --- a/src/DriverManager.php +++ b/src/DriverManager.php @@ -9,22 +9,15 @@ use Doctrine\DBAL\Driver\PDO; use Doctrine\DBAL\Driver\SQLite3; use Doctrine\DBAL\Driver\SQLSrv; +use Doctrine\DBAL\Exception\MalformedDsnException; +use Doctrine\DBAL\Tools\DsnParser; use Doctrine\Deprecations\Deprecation; use function array_keys; use function array_merge; -use function assert; use function class_implements; use function in_array; -use function is_string; use function is_subclass_of; -use function parse_str; -use function parse_url; -use function preg_replace; -use function rawurldecode; -use function str_replace; -use function strpos; -use function substr; /** * Factory for creating {@see Connection} instances. @@ -266,17 +259,6 @@ private static function createDriver(array $params): Driver throw Exception::driverRequired(); } - /** - * Normalizes the given connection URL path. - * - * @return string The normalized connection URL path - */ - private static function normalizeDatabaseUrlPath(string $urlPath): string - { - // Trim leading slash from URL path. - return substr($urlPath, 1); - } - /** * Extracts parts from a database URL, if present, and returns an * updated list of parameters. @@ -296,190 +278,29 @@ private static function parseDatabaseUrl(array $params): array return $params; } - // (pdo_)?sqlite3?:///... => (pdo_)?sqlite3?://localhost/... or else the URL will be invalid - $url = preg_replace('#^((?:pdo_)?sqlite3?):///#', '$1://localhost/', $params['url']); - assert($url !== null); - - $url = parse_url($url); - - if ($url === false) { - throw new Exception('Malformed parameter "url".'); - } - - foreach ($url as $param => $value) { - if (! is_string($value)) { - continue; - } - - $url[$param] = rawurldecode($value); - } - - $params = self::parseDatabaseUrlScheme($url['scheme'] ?? null, $params); - - if (isset($url['host'])) { - $params['host'] = $url['host']; - } - - if (isset($url['port'])) { - $params['port'] = $url['port']; - } - - if (isset($url['user'])) { - $params['user'] = $url['user']; - } - - if (isset($url['pass'])) { - $params['password'] = $url['pass']; - } - - $params = self::parseDatabaseUrlPath($url, $params); - $params = self::parseDatabaseUrlQuery($url, $params); - - return $params; - } - - /** - * Parses the given connection URL and resolves the given connection parameters. - * - * Assumes that the connection URL scheme is already parsed and resolved into the given connection parameters - * via {@see parseDatabaseUrlScheme}. - * - * @see parseDatabaseUrlScheme - * - * @param mixed[] $url The URL parts to evaluate. - * @param mixed[] $params The connection parameters to resolve. - * - * @return mixed[] The resolved connection parameters. - */ - private static function parseDatabaseUrlPath(array $url, array $params): array - { - if (! isset($url['path'])) { - return $params; - } - - $url['path'] = self::normalizeDatabaseUrlPath($url['path']); - - // If we do not have a known DBAL driver, we do not know any connection URL path semantics to evaluate - // and therefore treat the path as regular DBAL connection URL path. - if (! isset($params['driver'])) { - return self::parseRegularDatabaseUrlPath($url, $params); - } - - if (strpos($params['driver'], 'sqlite') !== false) { - return self::parseSqliteDatabaseUrlPath($url, $params); + Deprecation::trigger( + 'doctrine/dbal', + 'https://github.com/doctrine/dbal/pull/5843', + 'The "url" connection parameter is deprecated. Please use %s to parse a database url before calling %s.', + DsnParser::class, + self::class, + ); + + $parser = new DsnParser(self::$driverSchemeAliases); + try { + $parsedParams = $parser->parse($params['url']); + } catch (MalformedDsnException $e) { + throw new Exception('Malformed parameter "url".', 0, $e); } - return self::parseRegularDatabaseUrlPath($url, $params); - } - - /** - * Parses the query part of the given connection URL and resolves the given connection parameters. - * - * @param mixed[] $url The connection URL parts to evaluate. - * @param mixed[] $params The connection parameters to resolve. - * - * @return mixed[] The resolved connection parameters. - */ - private static function parseDatabaseUrlQuery(array $url, array $params): array - { - if (! isset($url['query'])) { - return $params; - } - - $query = []; - - parse_str($url['query'], $query); // simply ingest query as extra params, e.g. charset or sslmode - - return array_merge($params, $query); // parse_str wipes existing array elements - } - - /** - * Parses the given regular connection URL and resolves the given connection parameters. - * - * Assumes that the "path" URL part is already normalized via {@see normalizeDatabaseUrlPath}. - * - * @see normalizeDatabaseUrlPath - * - * @param mixed[] $url The regular connection URL parts to evaluate. - * @param mixed[] $params The connection parameters to resolve. - * - * @return mixed[] The resolved connection parameters. - */ - private static function parseRegularDatabaseUrlPath(array $url, array $params): array - { - $params['dbname'] = $url['path']; - - return $params; - } - - /** - * Parses the given SQLite connection URL and resolves the given connection parameters. - * - * Assumes that the "path" URL part is already normalized via {@see normalizeDatabaseUrlPath}. - * - * @see normalizeDatabaseUrlPath - * - * @param mixed[] $url The SQLite connection URL parts to evaluate. - * @param mixed[] $params The connection parameters to resolve. - * - * @return mixed[] The resolved connection parameters. - */ - private static function parseSqliteDatabaseUrlPath(array $url, array $params): array - { - if ($url['path'] === ':memory:') { - $params['memory'] = true; - - return $params; - } - - $params['path'] = $url['path']; // pdo_sqlite driver uses 'path' instead of 'dbname' key - - return $params; - } - - /** - * Parses the scheme part from given connection URL and resolves the given connection parameters. - * - * @param string|null $scheme The connection URL scheme, if available - * @param mixed[] $params The connection parameters to resolve. - * - * @return mixed[] The resolved connection parameters. - * - * @throws Exception If parsing failed or resolution is not possible. - */ - private static function parseDatabaseUrlScheme(?string $scheme, array $params): array - { - if ($scheme !== null) { + if (isset($parsedParams['driver'])) { // The requested driver from the URL scheme takes precedence // over the default custom driver from the connection parameters (if any). unset($params['driverClass']); - - // URL schemes must not contain underscores, but dashes are ok - $driver = str_replace('-', '_', $scheme); - - // If the driver is an alias (e.g. "postgres"), map it to the actual name ("pdo-pgsql"). - // Otherwise, let checkParams decide later if the driver exists. - if (isset(self::$driverSchemeAliases[$driver])) { - $actualDriver = self::$driverSchemeAliases[$driver]; - - Deprecation::trigger( - 'doctrine/dbal', - 'https://github.com/doctrine/dbal/pull/5697', - 'Relying on driver name aliases is deprecated. Use %s instead of %s.', - str_replace('_', '-', $actualDriver), - $driver, - ); - - $driver = $actualDriver; - } - - // The requested driver from the URL scheme takes precedence over the - // default driver from the connection parameters. - $params['driver'] = $driver; - - return $params; } + $params = array_merge($params, $parsedParams); + // If a schemeless connection URL is given, we require a default driver or default custom driver // as connection parameter. if (! isset($params['driverClass']) && ! isset($params['driver'])) { diff --git a/src/Exception/MalformedDsnException.php b/src/Exception/MalformedDsnException.php new file mode 100644 index 00000000000..01cd7c200b0 --- /dev/null +++ b/src/Exception/MalformedDsnException.php @@ -0,0 +1,14 @@ + */ + private array $schemeMapping; + + /** @param array $schemeMapping An array used to map DSN schemes to DBAL drivers */ + public function __construct(array $schemeMapping = []) + { + $this->schemeMapping = $schemeMapping; + } + + /** + * @psalm-return Params + * + * @throws MalformedDsnException + */ + public function parse(string $dsn): array + { + // (pdo_)?sqlite3?:///... => (pdo_)?sqlite3?://localhost/... or else the URL will be invalid + $url = preg_replace('#^((?:pdo_)?sqlite3?):///#', '$1://localhost/', $dsn); + assert($url !== null); + + $url = parse_url($url); + + if ($url === false) { + throw MalformedDsnException::new(); + } + + foreach ($url as $param => $value) { + if (! is_string($value)) { + continue; + } + + $url[$param] = rawurldecode($value); + } + + $params = []; + + if (isset($url['scheme'])) { + $params['driver'] = $this->parseDatabaseUrlScheme($url['scheme']); + } + + if (isset($url['host'])) { + $params['host'] = $url['host']; + } + + if (isset($url['port'])) { + $params['port'] = $url['port']; + } + + if (isset($url['user'])) { + $params['user'] = $url['user']; + } + + if (isset($url['pass'])) { + $params['password'] = $url['pass']; + } + + $params = $this->parseDatabaseUrlPath($url, $params); + $params = $this->parseDatabaseUrlQuery($url, $params); + + return $params; + } + + /** + * Parses the given connection URL and resolves the given connection parameters. + * + * Assumes that the connection URL scheme is already parsed and resolved into the given connection parameters + * via {@see parseDatabaseUrlScheme}. + * + * @see parseDatabaseUrlScheme + * + * @param mixed[] $url The URL parts to evaluate. + * @param mixed[] $params The connection parameters to resolve. + * + * @return mixed[] The resolved connection parameters. + */ + private function parseDatabaseUrlPath(array $url, array $params): array + { + if (! isset($url['path'])) { + return $params; + } + + $url['path'] = $this->normalizeDatabaseUrlPath($url['path']); + + // If we do not have a known DBAL driver, we do not know any connection URL path semantics to evaluate + // and therefore treat the path as a regular DBAL connection URL path. + if (! isset($params['driver'])) { + return $this->parseRegularDatabaseUrlPath($url, $params); + } + + if (strpos($params['driver'], 'sqlite') !== false) { + return $this->parseSqliteDatabaseUrlPath($url, $params); + } + + return $this->parseRegularDatabaseUrlPath($url, $params); + } + + /** + * Normalizes the given connection URL path. + * + * @return string The normalized connection URL path + */ + private function normalizeDatabaseUrlPath(string $urlPath): string + { + // Trim leading slash from URL path. + return substr($urlPath, 1); + } + + /** + * Parses the query part of the given connection URL and resolves the given connection parameters. + * + * @param mixed[] $url The connection URL parts to evaluate. + * @param mixed[] $params The connection parameters to resolve. + * + * @return mixed[] The resolved connection parameters. + */ + private function parseDatabaseUrlQuery(array $url, array $params): array + { + if (! isset($url['query'])) { + return $params; + } + + $query = []; + + parse_str($url['query'], $query); // simply ingest query as extra params, e.g. charset or sslmode + + return array_merge($params, $query); // parse_str wipes existing array elements + } + + /** + * Parses the given regular connection URL and resolves the given connection parameters. + * + * Assumes that the "path" URL part is already normalized via {@see normalizeDatabaseUrlPath}. + * + * @see normalizeDatabaseUrlPath + * + * @param mixed[] $url The regular connection URL parts to evaluate. + * @param mixed[] $params The connection parameters to resolve. + * + * @return mixed[] The resolved connection parameters. + */ + private function parseRegularDatabaseUrlPath(array $url, array $params): array + { + $params['dbname'] = $url['path']; + + return $params; + } + + /** + * Parses the given SQLite connection URL and resolves the given connection parameters. + * + * Assumes that the "path" URL part is already normalized via {@see normalizeDatabaseUrlPath}. + * + * @see normalizeDatabaseUrlPath + * + * @param mixed[] $url The SQLite connection URL parts to evaluate. + * @param mixed[] $params The connection parameters to resolve. + * + * @return mixed[] The resolved connection parameters. + */ + private function parseSqliteDatabaseUrlPath(array $url, array $params): array + { + if ($url['path'] === ':memory:') { + $params['memory'] = true; + + return $params; + } + + $params['path'] = $url['path']; // pdo_sqlite driver uses 'path' instead of 'dbname' key + + return $params; + } + + /** + * Parses the scheme part from given connection URL and resolves the given connection parameters. + * + * @return string The resolved driver. + */ + private function parseDatabaseUrlScheme(string $scheme): string + { + // URL schemes must not contain underscores, but dashes are ok + $driver = str_replace('-', '_', $scheme); + + // If the driver is an alias (e.g. "postgres"), map it to the actual name ("pdo-pgsql"). + // Otherwise, let checkParams decide later if the driver exists. + return $this->schemeMapping[$driver] ?? $driver; + } +} diff --git a/tests/DriverManagerTest.php b/tests/DriverManagerTest.php index 83e6ab66411..9df7ee534ba 100644 --- a/tests/DriverManagerTest.php +++ b/tests/DriverManagerTest.php @@ -10,6 +10,8 @@ use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Exception; use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Tools\DsnParser; +use Doctrine\Deprecations\PHPUnit\VerifyDeprecations; use PHPUnit\Framework\TestCase; use stdClass; @@ -21,6 +23,8 @@ class DriverManagerTest extends TestCase { + use VerifyDeprecations; + public function testCheckParams(): void { $this->expectException(Exception::class); @@ -147,7 +151,7 @@ public function testDatabaseUrlPrimaryReplica(): void * * @dataProvider databaseUrls */ - public function testDatabaseUrl($url, $expected): void + public function testDatabaseUrlDeprecated($url, $expected): void { $options = is_array($url) ? $url : ['url' => $url]; @@ -155,6 +159,40 @@ public function testDatabaseUrl($url, $expected): void $this->expectException(Exception::class); } + $this->expectDeprecationWithIdentifier('https://github.com/doctrine/dbal/pull/5843'); + $conn = DriverManager::getConnection($options); + + $params = $conn->getParams(); + foreach ($expected as $key => $value) { + if (in_array($key, ['driver', 'driverClass'], true)) { + self::assertInstanceOf($value, $conn->getDriver()); + } else { + self::assertEquals($value, $params[$key]); + } + } + } + + /** + * @param mixed $url + * @param mixed $expected + * + * @dataProvider databaseUrls + */ + public function testDatabaseUrl($url, $expected): void + { + if (is_array($url)) { + ['url' => $url] = $options = $url; + } else { + $options = []; + } + + $parser = new DsnParser(['mysql' => 'pdo_mysql', 'sqlite' => 'pdo_sqlite']); + $options = array_merge($options, $parser->parse($url)); + + if ($expected === false) { + $this->expectException(Exception::class); + } + $conn = DriverManager::getConnection($options); $params = $conn->getParams(); diff --git a/tests/Tools/DsnParserTest.php b/tests/Tools/DsnParserTest.php new file mode 100644 index 00000000000..f0a1c28b7a7 --- /dev/null +++ b/tests/Tools/DsnParserTest.php @@ -0,0 +1,167 @@ + 'mysqli', 'sqlite' => 'sqlite3']); + $actual = $parser->parse($dsn); + + // We don't care about the order of the array keys, so let's normalize both + // arrays before comparing them. + ksort($expected); + ksort($actual); + + self::assertSame($expected, $actual); + } + + /** @psalm-return iterable */ + public function databaseUrls(): iterable + { + return [ + 'simple URL' => [ + 'mysql://foo:bar@localhost/baz', + [ + 'user' => 'foo', + 'password' => 'bar', + 'host' => 'localhost', + 'dbname' => 'baz', + 'driver' => 'mysqli', + ], + ], + 'simple URL with port' => [ + 'mysql://foo:bar@localhost:11211/baz', + [ + 'user' => 'foo', + 'password' => 'bar', + 'host' => 'localhost', + 'port' => 11211, + 'dbname' => 'baz', + 'driver' => 'mysqli', + ], + ], + 'sqlite relative URL with host' => [ + 'sqlite://localhost/foo/dbname.sqlite', + [ + 'host' => 'localhost', + 'path' => 'foo/dbname.sqlite', + 'driver' => 'sqlite3', + ], + ], + 'sqlite absolute URL with host' => [ + 'sqlite://localhost//tmp/dbname.sqlite', + [ + 'host' => 'localhost', + 'path' => '/tmp/dbname.sqlite', + 'driver' => 'sqlite3', + ], + ], + 'sqlite relative URL without host' => [ + 'sqlite:///foo/dbname.sqlite', + [ + 'host' => 'localhost', + 'path' => 'foo/dbname.sqlite', + 'driver' => 'sqlite3', + ], + ], + 'sqlite absolute URL without host' => [ + 'sqlite:////tmp/dbname.sqlite', + [ + 'host' => 'localhost', + 'path' => '/tmp/dbname.sqlite', + 'driver' => 'sqlite3', + ], + ], + 'sqlite memory' => [ + 'sqlite:///:memory:', + [ + 'host' => 'localhost', + 'memory' => true, + 'driver' => 'sqlite3', + ], + ], + 'sqlite memory with host' => [ + 'sqlite://localhost/:memory:', + [ + 'host' => 'localhost', + 'memory' => true, + 'driver' => 'sqlite3', + ], + ], + 'query params from URL are used as extra params' => [ + 'mysql://foo:bar@localhost/dbname?charset=UTF-8', + [ + 'user' => 'foo', + 'password' => 'bar', + 'host' => 'localhost', + 'dbname' => 'dbname', + 'charset' => 'UTF-8', + 'driver' => 'mysqli', + ], + ], + 'simple URL with fallthrough scheme not defined in map' => [ + 'sqlsrv://foo:bar@localhost/baz', + [ + 'user' => 'foo', + 'password' => 'bar', + 'host' => 'localhost', + 'dbname' => 'baz', + 'driver' => 'sqlsrv', + ], + ], + 'simple URL with fallthrough scheme containing dashes works' => [ + 'pdo-mysql://foo:bar@localhost/baz', + [ + 'user' => 'foo', + 'password' => 'bar', + 'host' => 'localhost', + 'dbname' => 'baz', + 'driver' => 'pdo_mysql', + ], + ], + 'simple URL with percent encoding' => [ + 'mysql://foo%3A:bar%2F@localhost/baz+baz%40', + [ + 'user' => 'foo:', + 'password' => 'bar/', + 'host' => 'localhost', + 'dbname' => 'baz+baz@', + 'driver' => 'mysqli', + ], + ], + 'simple URL with percent sign in password' => [ + 'mysql://foo:bar%25bar@localhost/baz', + [ + 'user' => 'foo', + 'password' => 'bar%bar', + 'host' => 'localhost', + 'dbname' => 'baz', + 'driver' => 'mysqli', + ], + ], + 'URL without scheme' => [ + '//foo:bar@localhost/baz', + [ + 'user' => 'foo', + 'password' => 'bar', + 'host' => 'localhost', + 'dbname' => 'baz', + ], + ], + ]; + } +}