Files
cms-core/Classes/Database/ConnectionPool.php
T

414 lines
17 KiB
PHP

<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Database;
use Doctrine\DBAL\Driver\Middleware as DriverMiddleware;
use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Exception\MalformedDsnException;
use Doctrine\DBAL\Tools\DsnParser;
use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Types\Types;
use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Database\Middleware\UsableForConnectionInterface;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Database\Schema\SchemaManager\CoreSchemaManagerFactory;
use TYPO3\CMS\Core\Database\Schema\Types\DateTimeType;
use TYPO3\CMS\Core\Database\Schema\Types\DateType;
use TYPO3\CMS\Core\Database\Schema\Types\SetType;
use TYPO3\CMS\Core\Database\Schema\Types\TimeType;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Manager that handles opening/retrieving database connections.
*
* It's a facade to the actual Doctrine DBAL DriverManager that implements TYPO3
* specific functionality like mapping individual tables to different database
* connections.
*
* getConnectionForTable() is the only supported way to get a connection that
* honors the table mapping configuration.
*/
#[Autoconfigure(public: true)]
class ConnectionPool
{
/**
* @var string
*/
public const DEFAULT_CONNECTION_NAME = 'Default';
/**
* @var Connection[]
*/
protected array $connections = [];
/**
* @var array<non-empty-string,class-string>
* @todo Needs to be refactored. Only MySQL and MariaDB support this type, using this to register the type AND
* add mappings to all connections, even unsupported connections for SQLite or PostgreSQL is not correct,
* and needs to be respected. Or the type needs to provide working fallbacks for unsupported platforms.
*/
protected static array $customDoctrineTypes = [
SetType::TYPE => SetType::class,
];
/**
* @var array<non-empty-string,class-string>
* @todo Needs to be refactored to differentiate between type registration and platform specific type mapping.
*/
protected static array $overrideDoctrineTypes = [
Types::DATE_MUTABLE => DateType::class,
Types::DATETIME_MUTABLE => DateTimeType::class,
Types::DATETIME_IMMUTABLE => DateTimeType::class,
Types::TIME_MUTABLE => TimeType::class,
];
public function __construct(
protected readonly ContainerInterface $container,
protected readonly CoreSchemaManagerFactory $coreSchemaManagerFactory,
protected readonly DriverMiddlewareService $driverMiddlewareService,
) {}
/**
* Creates a connection object based on the specified table name.
*
* This is the official entry point to get a database connection to ensure
* that the mapping of table names to database connections is honored.
*
* @param string $tableName
*/
public function getConnectionForTable(string $tableName): Connection
{
if (empty($tableName)) {
throw new \UnexpectedValueException(
'ConnectionPool->getConnectionForTable() requires a table name to be provided.',
1459421719
);
}
$connectionName = self::DEFAULT_CONNECTION_NAME;
if (!empty($GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping'][$tableName])) {
$connectionName = (string)$GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping'][$tableName];
}
return $this->getConnectionByName($connectionName);
}
/**
* Creates a connection object based on the specified identifier.
*
* This method should only be used in edge cases. Use getConnectionForTable() so
* that the tablename<>databaseConnection mapping will be taken into account.
*
* @param string $connectionName
* @throws \Doctrine\DBAL\Exception
*/
public function getConnectionByName(string $connectionName): Connection
{
if (empty($connectionName)) {
throw new \UnexpectedValueException(
'ConnectionPool->getConnectionByName() requires a connection name to be provided.',
1459422125
);
}
if (isset($this->connections[$connectionName])) {
return $this->connections[$connectionName];
}
$this->connections[$connectionName] = $this->getDatabaseConnection(
$connectionName,
$this->getConnectionParams($connectionName),
);
return $this->connections[$connectionName];
}
protected function getConnectionParams(string $connectionName): array
{
$connectionParams = $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][$connectionName] ?? [];
if (empty($connectionParams)) {
throw new \RuntimeException(
'The requested database connection named "' . $connectionName . '" has not been configured.',
1459422492
);
}
if (!empty($connectionParams['url'])) {
$dsnUrl = $connectionParams['url'];
unset($connectionParams['url']);
try {
$parsedParams = (new DsnParser())->parse($dsnUrl);
} catch (MalformedDsnException $e) {
throw new \UnexpectedValueException('Malformed connection parameter "url".', 1750964898, $e);
}
$connectionParams = [...$connectionParams, ...$parsedParams];
}
if (empty($connectionParams['wrapperClass'])) {
$connectionParams['wrapperClass'] = Connection::class;
}
if (!is_a($connectionParams['wrapperClass'], Connection::class, true)) {
throw new \UnexpectedValueException(
'The "wrapperClass" for the connection name "' . $connectionName
. '" needs to be a subclass of "' . Connection::class . '".',
1459422968
);
}
// Ensure integer value for port.
if (array_key_exists('port', $connectionParams)) {
$connectionParams['port'] = (int)($connectionParams['port'] ?? 0);
}
return $this->migrateConnectionParams($connectionName, $connectionParams);
}
private function migrateConnectionParams(string $connectionName, #[\SensitiveParameter] array $params): array
{
$params['defaultTableOptions'] ??= [];
$params = $this->removeInvalidConnectionParams($params);
return $this->ensureDefaultConnectionCharset($params);
}
/**
* Clean up invalid connection parameters.
*/
private function removeInvalidConnectionParams(#[\SensitiveParameter] array $params): array
{
// Remove defaultTableOptions for unsupported databases
unset($params['tableoptions']);
// Ensure to remove `defaultTableOptions` for drivers not supporting it.
if (!in_array((string)($params['driver'] ?? ''), ['mysqli', 'pdo_mysql'], true)) {
unset($params['defaultTableOptions']);
return $params;
}
// ENGINE is a TYPO3 custom option not handled by doctrine/dbal by a custom implementation,
// see `MySQLCompatibleAlterTablePlatformAwareTrait`
$allowedDefaultTableOptions = ['charset', 'collation', 'engine'];
$currentDefaultTableOptionsArrayKeys = array_keys($params['defaultTableOptions']);
foreach ($currentDefaultTableOptionsArrayKeys as $optionIdentifier) {
if (!in_array($optionIdentifier, $allowedDefaultTableOptions, true)) {
unset($params['defaultTableOptions'][$optionIdentifier]);
}
}
// Remove if empty.
if ($params['defaultTableOptions'] === []) {
unset($params['defaultTableOptions']);
}
return $params;
}
/**
* Set a suiting UTF-8 connection charset when nothing is set in connection configuration for `charset`.
*
* @todo Investigate how to deal with missing defaultTableOptions for MariaDB and MySQL connections,
* which may be already partially set even when charset is missing.
*/
private function ensureDefaultConnectionCharset(#[\SensitiveParameter] array $params): array
{
if (!array_key_exists('charset', $params) || !is_string($params['charset']) || $params['charset'] === '') {
$params['charset'] = 'utf8';
// @todo Add `charset = utf8mb4` for MySQL/MariaDB as default connection charset in 14.0 as breaking change.
}
return $params;
}
/**
* Return any doctrine driver middlewares, that may have been set up in:
* - for all configured connections
* - $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections']['Default']['driverMiddlewares'] for a specific connection
*/
protected function getDriverMiddlewares(string $connectionName, #[\SensitiveParameter] array $connectionParams): array
{
$driverMiddlewares = $this->getOrderedConnectionDriverMiddlewareConfiguration($connectionName, $connectionParams);
$middlewares = [];
foreach ($driverMiddlewares as $middlewareConfiguration) {
$className = $middlewareConfiguration['target'];
$disabled = $middlewareConfiguration['disabled'];
if ($disabled === true) {
// Middleware disabled, skip to next middleware.
continue;
}
$middlewares[] = GeneralUtility::makeInstance($className);
}
return $middlewares;
}
/**
* @internal only for `ext:lowlevel` usage to retrieve configuration overview. *
* @return array
*/
public function getConnectionMiddlewareConfigurationArrayForLowLevelConfiguration(): array
{
$configurationArray = [
'Raw' => [
'GlobalDriverMiddlewares' => $GLOBALS['TYPO3_CONF_VARS']['DB']['globalDriverMiddlewares'] ?? [],
'Connections' => [],
],
'Connections' => [],
];
foreach (array_keys($GLOBALS['TYPO3_CONF_VARS']['DB']['Connections']) as $connectionName) {
$connectionParams = $this->getConnectionParams($connectionName);
$configurationArray['Raw']['Connections'][$connectionName] = $connectionParams;
$configurationArray['Connections'][$connectionName] = $this->getOrderedConnectionDriverMiddlewareConfiguration($connectionName, $connectionParams);
}
return $configurationArray;
}
/**
* @param array $connectionParams
* @return array<non-empty-string, array{target: class-string, disabled: bool, after: string[], before: string[], type: string}>
*/
protected function getOrderedConnectionDriverMiddlewareConfiguration(string $connectionName, #[\SensitiveParameter] array $connectionParams): array
{
/** @var array<non-empty-string, array{target: class-string, disabled: bool, after: string[], before: string[], type: string}> $driverMiddlewares */
$driverMiddlewares = [];
foreach ($GLOBALS['TYPO3_CONF_VARS']['DB']['globalDriverMiddlewares'] ?? [] as $identifier => $middleware) {
$identifier = (string)$identifier;
$driverMiddlewares[$identifier] = $this->driverMiddlewareService->ensureCompleteMiddlewareConfiguration($middleware);
$driverMiddlewares[$identifier]['type'] = 'global';
}
foreach ($connectionParams['driverMiddlewares'] ?? [] as $identifier => $middleware) {
$identifier = (string)$identifier;
// Merge driverMiddlewares over globalDriverMiddlewares
$middleware = array_replace($driverMiddlewares[$identifier] ?? [], $middleware);
$middleware = $this->driverMiddlewareService->ensureCompleteMiddlewareConfiguration($middleware);
$driverMiddlewares[$identifier] = $middleware;
$driverMiddlewares[$identifier]['type'] = $driverMiddlewares[$identifier]['type']
? 'global-with-connection-override'
: 'connection';
}
$driverMiddlewares = array_filter($driverMiddlewares, static function (array $middleware) use ($connectionName, $connectionParams): bool {
$className = $middleware['target'];
$classImplements = class_exists($className) ? (class_implements($className) ?: []) : [];
if (!in_array(DriverMiddleware::class, $classImplements, true)) {
throw new \UnexpectedValueException(
sprintf(
'Doctrine Driver Middleware "%s" must implement \Doctrine\DBAL\Driver\Middleware',
$className
),
1677958727
);
}
if (in_array(UsableForConnectionInterface::class, $classImplements, true)) {
return GeneralUtility::makeInstance($middleware['target'])->canBeUsedForConnection($connectionName, $connectionParams);
}
return true;
});
return $this->driverMiddlewareService->order($driverMiddlewares);
}
/**
* Creates a connection object based on the specified parameters
*/
protected function getDatabaseConnection(string $connectionName, #[\SensitiveParameter] array $connectionParams): Connection
{
self::registerDoctrineTypes();
$middlewares = $this->getDriverMiddlewares($connectionName, $connectionParams);
$configuration = (new Configuration())
->setContainer($this->container)
->setMiddlewares($middlewares)
// @link https://github.com/doctrine/dbal/blob/3.7.x/UPGRADE.md#deprecated-not-setting-a-schema-manager-factory
->setSchemaManagerFactory($this->coreSchemaManagerFactory);
/** @var Connection $conn */
$conn = DriverManager::getConnection($connectionParams, $configuration);
$conn->prepareConnection($connectionParams['initCommands'] ?? '');
// Register all custom data types in the type mapping
foreach (self::$customDoctrineTypes as $type => $className) {
$conn->getDatabasePlatform()->registerDoctrineTypeMapping($type, $type);
}
// Register all override data types in the type mapping
foreach (self::$overrideDoctrineTypes as $type => $className) {
$conn->getDatabasePlatform()->registerDoctrineTypeMapping($type, $type);
}
return $conn;
}
/**
* Returns the connection specific query builder object that can be used to build
* complex SQL queries using and object-oriented approach.
*/
public function getQueryBuilderForTable(string $tableName): QueryBuilder
{
if (empty($tableName)) {
throw new \UnexpectedValueException(
'ConnectionPool->getQueryBuilderForTable() requires a connection name to be provided.',
1459423448
);
}
return $this->getConnectionForTable($tableName)->createQueryBuilder();
}
/**
* Returns an array containing the names of all currently configured connections.
*
* This method should only be used in edge cases. Use getConnectionForTable() so
* that the tablename<>databaseConnection mapping will be taken into account.
*
* @internal
*/
public function getConnectionNames(): array
{
return array_keys($GLOBALS['TYPO3_CONF_VARS']['DB']['Connections']);
}
/**
* Register custom and override Doctrine data types implemented by TYPO3.
* This method is needed by Schema parser to register the types as it does
* not require a database connection and thus the types don't get registered
* automatically.
*
* @internal
*/
public static function registerDoctrineTypes(): void
{
// Register custom data types
foreach (self::$customDoctrineTypes as $type => $className) {
if (!Type::hasType($type)) {
Type::addType($type, $className);
}
}
// Override data types
foreach (self::$overrideDoctrineTypes as $type => $className) {
if (!Type::hasType($type)) {
Type::addType($type, $className);
continue;
}
Type::overrideType($type, $className);
}
}
/**
* Used to be used by functional tests
* to close statically stored connections, in order
* to use new connection in between single tests.
*
* This is a no-op nowadays since `$this->connections`
* is no longer static and can be removed without replacement,
* once testing framework is adapted to avoid calling this method.
*/
public function resetConnections(): void {}
}