1<?php
2
3namespace Doctrine\DBAL;
4
5use Doctrine\Common\EventManager;
6use Doctrine\DBAL\Driver\DrizzlePDOMySql;
7use Doctrine\DBAL\Driver\IBMDB2;
8use Doctrine\DBAL\Driver\Mysqli;
9use Doctrine\DBAL\Driver\OCI8;
10use Doctrine\DBAL\Driver\PDO;
11use Doctrine\DBAL\Driver\SQLAnywhere;
12use Doctrine\DBAL\Driver\SQLSrv;
13
14use function array_keys;
15use function array_merge;
16use function class_implements;
17use function in_array;
18use function is_string;
19use function is_subclass_of;
20use function parse_str;
21use function parse_url;
22use function preg_replace;
23use function rawurldecode;
24use function str_replace;
25use function strpos;
26use function substr;
27
28/**
29 * Factory for creating {@link Connection} instances.
30 *
31 * @psalm-type OverrideParams = array{
32 *     charset?: string,
33 *     dbname?: string,
34 *     default_dbname?: string,
35 *     driver?: key-of<self::DRIVER_MAP>,
36 *     driverClass?: class-string<Driver>,
37 *     driverOptions?: array<mixed>,
38 *     host?: string,
39 *     password?: string,
40 *     path?: string,
41 *     pdo?: \PDO,
42 *     platform?: Platforms\AbstractPlatform,
43 *     port?: int,
44 *     user?: string,
45 * }
46 * @psalm-type Params = array{
47 *     charset?: string,
48 *     dbname?: string,
49 *     default_dbname?: string,
50 *     driver?: key-of<self::DRIVER_MAP>,
51 *     driverClass?: class-string<Driver>,
52 *     driverOptions?: array<mixed>,
53 *     host?: string,
54 *     keepSlave?: bool,
55 *     keepReplica?: bool,
56 *     master?: OverrideParams,
57 *     memory?: bool,
58 *     password?: string,
59 *     path?: string,
60 *     pdo?: \PDO,
61 *     platform?: Platforms\AbstractPlatform,
62 *     port?: int,
63 *     primary?: OverrideParams,
64 *     replica?: array<OverrideParams>,
65 *     sharding?: array<string,mixed>,
66 *     slaves?: array<OverrideParams>,
67 *     user?: string,
68 *     wrapperClass?: class-string<Connection>,
69 * }
70 */
71final class DriverManager
72{
73    /**
74     * List of supported drivers and their mappings to the driver classes.
75     *
76     * To add your own driver use the 'driverClass' parameter to {@link DriverManager::getConnection()}.
77     */
78    private const DRIVER_MAP = [
79        'pdo_mysql'          => PDO\MySQL\Driver::class,
80        'pdo_sqlite'         => PDO\SQLite\Driver::class,
81        'pdo_pgsql'          => PDO\PgSQL\Driver::class,
82        'pdo_oci'            => PDO\OCI\Driver::class,
83        'oci8'               => OCI8\Driver::class,
84        'ibm_db2'            => IBMDB2\Driver::class,
85        'pdo_sqlsrv'         => PDO\SQLSrv\Driver::class,
86        'mysqli'             => Mysqli\Driver::class,
87        'drizzle_pdo_mysql'  => DrizzlePDOMySql\Driver::class,
88        'sqlanywhere'        => SQLAnywhere\Driver::class,
89        'sqlsrv'             => SQLSrv\Driver::class,
90    ];
91
92    /**
93     * List of URL schemes from a database URL and their mappings to driver.
94     *
95     * @var string[]
96     */
97    private static $driverSchemeAliases = [
98        'db2'        => 'ibm_db2',
99        'mssql'      => 'pdo_sqlsrv',
100        'mysql'      => 'pdo_mysql',
101        'mysql2'     => 'pdo_mysql', // Amazon RDS, for some weird reason
102        'postgres'   => 'pdo_pgsql',
103        'postgresql' => 'pdo_pgsql',
104        'pgsql'      => 'pdo_pgsql',
105        'sqlite'     => 'pdo_sqlite',
106        'sqlite3'    => 'pdo_sqlite',
107    ];
108
109    /**
110     * Private constructor. This class cannot be instantiated.
111     *
112     * @codeCoverageIgnore
113     */
114    private function __construct()
115    {
116    }
117
118    /**
119     * Creates a connection object based on the specified parameters.
120     * This method returns a Doctrine\DBAL\Connection which wraps the underlying
121     * driver connection.
122     *
123     * $params must contain at least one of the following.
124     *
125     * Either 'driver' with one of the array keys of {@link DRIVER_MAP},
126     * OR 'driverClass' that contains the full class name (with namespace) of the
127     * driver class to instantiate.
128     *
129     * Other (optional) parameters:
130     *
131     * <b>user (string)</b>:
132     * The username to use when connecting.
133     *
134     * <b>password (string)</b>:
135     * The password to use when connecting.
136     *
137     * <b>driverOptions (array)</b>:
138     * Any additional driver-specific options for the driver. These are just passed
139     * through to the driver.
140     *
141     * <b>pdo</b>:
142     * You can pass an existing PDO instance through this parameter. The PDO
143     * instance will be wrapped in a Doctrine\DBAL\Connection.
144     * This feature is deprecated and no longer supported in 3.0.x version.
145     *
146     * <b>wrapperClass</b>:
147     * You may specify a custom wrapper class through the 'wrapperClass'
148     * parameter but this class MUST inherit from Doctrine\DBAL\Connection.
149     *
150     * <b>driverClass</b>:
151     * The driver class to use.
152     *
153     * @param array<string,mixed> $params
154     * @param Configuration|null  $config       The configuration to use.
155     * @param EventManager|null   $eventManager The event manager to use.
156     * @psalm-param array{
157     *     charset?: string,
158     *     dbname?: string,
159     *     default_dbname?: string,
160     *     driver?: key-of<self::DRIVER_MAP>,
161     *     driverClass?: class-string<Driver>,
162     *     driverOptions?: array<mixed>,
163     *     host?: string,
164     *     keepSlave?: bool,
165     *     keepReplica?: bool,
166     *     master?: OverrideParams,
167     *     memory?: bool,
168     *     password?: string,
169     *     path?: string,
170     *     pdo?: \PDO,
171     *     platform?: Platforms\AbstractPlatform,
172     *     port?: int,
173     *     primary?: OverrideParams,
174     *     replica?: array<OverrideParams>,
175     *     sharding?: array<string,mixed>,
176     *     slaves?: array<OverrideParams>,
177     *     user?: string,
178     *     wrapperClass?: class-string<T>,
179     * } $params
180     * @phpstan-param array<string,mixed> $params
181     *
182     * @psalm-return ($params is array{wrapperClass:mixed} ? T : Connection)
183     *
184     * @throws Exception
185     *
186     * @template T of Connection
187     */
188    public static function getConnection(
189        array $params,
190        ?Configuration $config = null,
191        ?EventManager $eventManager = null
192    ): Connection {
193        // create default config and event manager, if not set
194        if (! $config) {
195            $config = new Configuration();
196        }
197
198        if (! $eventManager) {
199            $eventManager = new EventManager();
200        }
201
202        $params = self::parseDatabaseUrl($params);
203
204        // @todo: deprecated, notice thrown by connection constructor
205        if (isset($params['master'])) {
206            $params['master'] = self::parseDatabaseUrl($params['master']);
207        }
208
209        // @todo: deprecated, notice thrown by connection constructor
210        if (isset($params['slaves'])) {
211            foreach ($params['slaves'] as $key => $slaveParams) {
212                $params['slaves'][$key] = self::parseDatabaseUrl($slaveParams);
213            }
214        }
215
216        // URL support for PrimaryReplicaConnection
217        if (isset($params['primary'])) {
218            $params['primary'] = self::parseDatabaseUrl($params['primary']);
219        }
220
221        if (isset($params['replica'])) {
222            foreach ($params['replica'] as $key => $replicaParams) {
223                $params['replica'][$key] = self::parseDatabaseUrl($replicaParams);
224            }
225        }
226
227        // URL support for PoolingShardConnection
228        if (isset($params['global'])) {
229            $params['global'] = self::parseDatabaseUrl($params['global']);
230        }
231
232        if (isset($params['shards'])) {
233            foreach ($params['shards'] as $key => $shardParams) {
234                $params['shards'][$key] = self::parseDatabaseUrl($shardParams);
235            }
236        }
237
238        // check for existing pdo object
239        if (isset($params['pdo']) && ! $params['pdo'] instanceof \PDO) {
240            throw Exception::invalidPdoInstance();
241        }
242
243        if (isset($params['pdo'])) {
244            $params['pdo']->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
245            $params['driver'] = 'pdo_' . $params['pdo']->getAttribute(\PDO::ATTR_DRIVER_NAME);
246        }
247
248        $driver = self::createDriver($params);
249
250        $wrapperClass = Connection::class;
251        if (isset($params['wrapperClass'])) {
252            if (! is_subclass_of($params['wrapperClass'], $wrapperClass)) {
253                throw Exception::invalidWrapperClass($params['wrapperClass']);
254            }
255
256            /** @var class-string<Connection> $wrapperClass */
257            $wrapperClass = $params['wrapperClass'];
258        }
259
260        return new $wrapperClass($params, $driver, $config, $eventManager);
261    }
262
263    /**
264     * Returns the list of supported drivers.
265     *
266     * @return string[]
267     */
268    public static function getAvailableDrivers(): array
269    {
270        return array_keys(self::DRIVER_MAP);
271    }
272
273    /**
274     * @param array<string,mixed> $params
275     * @psalm-param Params $params
276     * @phpstan-param array<string,mixed> $params
277     *
278     * @throws Exception
279     */
280    private static function createDriver(array $params): Driver
281    {
282        if (isset($params['driverClass'])) {
283            $interfaces = class_implements($params['driverClass'], true);
284
285            if ($interfaces === false || ! in_array(Driver::class, $interfaces)) {
286                throw Exception::invalidDriverClass($params['driverClass']);
287            }
288
289            return new $params['driverClass']();
290        }
291
292        if (isset($params['driver'])) {
293            if (! isset(self::DRIVER_MAP[$params['driver']])) {
294                throw Exception::unknownDriver($params['driver'], array_keys(self::DRIVER_MAP));
295            }
296
297            $class = self::DRIVER_MAP[$params['driver']];
298
299            return new $class();
300        }
301
302        throw Exception::driverRequired();
303    }
304
305    /**
306     * Normalizes the given connection URL path.
307     *
308     * @return string The normalized connection URL path
309     */
310    private static function normalizeDatabaseUrlPath(string $urlPath): string
311    {
312        // Trim leading slash from URL path.
313        return substr($urlPath, 1);
314    }
315
316    /**
317     * Extracts parts from a database URL, if present, and returns an
318     * updated list of parameters.
319     *
320     * @param mixed[] $params The list of parameters.
321     * @psalm-param Params $params
322     * @phpstan-param array<string,mixed> $params
323     *
324     * @return mixed[] A modified list of parameters with info from a database
325     *                 URL extracted into indidivual parameter parts.
326     * @psalm-return Params
327     * @phpstan-return array<string,mixed>
328     *
329     * @throws Exception
330     */
331    private static function parseDatabaseUrl(array $params): array
332    {
333        if (! isset($params['url'])) {
334            return $params;
335        }
336
337        // (pdo_)?sqlite3?:///... => (pdo_)?sqlite3?://localhost/... or else the URL will be invalid
338        $url = preg_replace('#^((?:pdo_)?sqlite3?):///#', '$1://localhost/', $params['url']);
339        $url = parse_url($url);
340
341        if ($url === false) {
342            throw new Exception('Malformed parameter "url".');
343        }
344
345        foreach ($url as $param => $value) {
346            if (! is_string($value)) {
347                continue;
348            }
349
350            $url[$param] = rawurldecode($value);
351        }
352
353        // If we have a connection URL, we have to unset the default PDO instance connection parameter (if any)
354        // as we cannot merge connection details from the URL into the PDO instance (URL takes precedence).
355        unset($params['pdo']);
356
357        $params = self::parseDatabaseUrlScheme($url['scheme'] ?? null, $params);
358
359        if (isset($url['host'])) {
360            $params['host'] = $url['host'];
361        }
362
363        if (isset($url['port'])) {
364            $params['port'] = $url['port'];
365        }
366
367        if (isset($url['user'])) {
368            $params['user'] = $url['user'];
369        }
370
371        if (isset($url['pass'])) {
372            $params['password'] = $url['pass'];
373        }
374
375        $params = self::parseDatabaseUrlPath($url, $params);
376        $params = self::parseDatabaseUrlQuery($url, $params);
377
378        return $params;
379    }
380
381    /**
382     * Parses the given connection URL and resolves the given connection parameters.
383     *
384     * Assumes that the connection URL scheme is already parsed and resolved into the given connection parameters
385     * via {@link parseDatabaseUrlScheme}.
386     *
387     * @see parseDatabaseUrlScheme
388     *
389     * @param mixed[] $url    The URL parts to evaluate.
390     * @param mixed[] $params The connection parameters to resolve.
391     *
392     * @return mixed[] The resolved connection parameters.
393     */
394    private static function parseDatabaseUrlPath(array $url, array $params): array
395    {
396        if (! isset($url['path'])) {
397            return $params;
398        }
399
400        $url['path'] = self::normalizeDatabaseUrlPath($url['path']);
401
402        // If we do not have a known DBAL driver, we do not know any connection URL path semantics to evaluate
403        // and therefore treat the path as regular DBAL connection URL path.
404        if (! isset($params['driver'])) {
405            return self::parseRegularDatabaseUrlPath($url, $params);
406        }
407
408        if (strpos($params['driver'], 'sqlite') !== false) {
409            return self::parseSqliteDatabaseUrlPath($url, $params);
410        }
411
412        return self::parseRegularDatabaseUrlPath($url, $params);
413    }
414
415    /**
416     * Parses the query part of the given connection URL and resolves the given connection parameters.
417     *
418     * @param mixed[] $url    The connection URL parts to evaluate.
419     * @param mixed[] $params The connection parameters to resolve.
420     *
421     * @return mixed[] The resolved connection parameters.
422     */
423    private static function parseDatabaseUrlQuery(array $url, array $params): array
424    {
425        if (! isset($url['query'])) {
426            return $params;
427        }
428
429        $query = [];
430
431        parse_str($url['query'], $query); // simply ingest query as extra params, e.g. charset or sslmode
432
433        return array_merge($params, $query); // parse_str wipes existing array elements
434    }
435
436    /**
437     * Parses the given regular connection URL and resolves the given connection parameters.
438     *
439     * Assumes that the "path" URL part is already normalized via {@link normalizeDatabaseUrlPath}.
440     *
441     * @see normalizeDatabaseUrlPath
442     *
443     * @param mixed[] $url    The regular connection URL parts to evaluate.
444     * @param mixed[] $params The connection parameters to resolve.
445     *
446     * @return mixed[] The resolved connection parameters.
447     */
448    private static function parseRegularDatabaseUrlPath(array $url, array $params): array
449    {
450        $params['dbname'] = $url['path'];
451
452        return $params;
453    }
454
455    /**
456     * Parses the given SQLite connection URL and resolves the given connection parameters.
457     *
458     * Assumes that the "path" URL part is already normalized via {@link normalizeDatabaseUrlPath}.
459     *
460     * @see normalizeDatabaseUrlPath
461     *
462     * @param mixed[] $url    The SQLite connection URL parts to evaluate.
463     * @param mixed[] $params The connection parameters to resolve.
464     *
465     * @return mixed[] The resolved connection parameters.
466     */
467    private static function parseSqliteDatabaseUrlPath(array $url, array $params): array
468    {
469        if ($url['path'] === ':memory:') {
470            $params['memory'] = true;
471
472            return $params;
473        }
474
475        $params['path'] = $url['path']; // pdo_sqlite driver uses 'path' instead of 'dbname' key
476
477        return $params;
478    }
479
480    /**
481     * Parses the scheme part from given connection URL and resolves the given connection parameters.
482     *
483     * @param string|null $scheme The connection URL scheme, if available
484     * @param mixed[]     $params The connection parameters to resolve.
485     *
486     * @return mixed[] The resolved connection parameters.
487     *
488     * @throws Exception If parsing failed or resolution is not possible.
489     */
490    private static function parseDatabaseUrlScheme($scheme, array $params): array
491    {
492        if ($scheme !== null) {
493            // The requested driver from the URL scheme takes precedence
494            // over the default custom driver from the connection parameters (if any).
495            unset($params['driverClass']);
496
497            // URL schemes must not contain underscores, but dashes are ok
498            $driver = str_replace('-', '_', $scheme);
499
500            // The requested driver from the URL scheme takes precedence over the
501            // default driver from the connection parameters. If the driver is
502            // an alias (e.g. "postgres"), map it to the actual name ("pdo-pgsql").
503            // Otherwise, let checkParams decide later if the driver exists.
504            $params['driver'] = self::$driverSchemeAliases[$driver] ?? $driver;
505
506            return $params;
507        }
508
509        // If a schemeless connection URL is given, we require a default driver or default custom driver
510        // as connection parameter.
511        if (! isset($params['driverClass']) && ! isset($params['driver'])) {
512            throw Exception::driverRequired($params['url']);
513        }
514
515        return $params;
516    }
517}
518