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/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..7e8d2785fda --- /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 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/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', + ], + ], + ]; + } +}