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

506 lines
20 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\ArrayParameterType;
use Doctrine\DBAL\Configuration as DoctrineConfiguration;
use Doctrine\DBAL\Connection as DoctrineConnection;
use Doctrine\DBAL\Connection\StaticServerVersionProvider;
use Doctrine\DBAL\Driver;
use Doctrine\DBAL\Driver\Connection as ConnectionInterface;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Platforms\MariaDBPlatform as DoctrineMariaDBPlatform;
use Doctrine\DBAL\Platforms\MySQLPlatform as DoctrineMySQLPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform as DoctrinePostgreSQLPlatform;
use Doctrine\DBAL\Result;
use Doctrine\DBAL\ServerVersionProvider;
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Database\Platform\PlatformInformation;
use TYPO3\CMS\Core\Database\Query\BulkInsertQuery;
use TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Database\Schema\SchemaInformation;
use TYPO3\CMS\Core\Package\Cache\PackageDependentCacheIdentifier;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Note: This is a fragile base class. It owns no contract of its own and specializes Doctrine's
* concrete Connection by overriding quoting, insert/update/delete and connect behavior.
* Subclassing is forced on us though: Doctrine's DriverManager only accepts a `wrapperClass` that
* is a Doctrine\DBAL\Connection, and DBAL exposes no high-level connection interface to compose
* against, so a real composition decorator is not possible.
*
* Integrators may in turn point the `wrapperClass` connection option at a subclass of this
* class, but because of the fragility described above such subclasses are not covered by the
* backward-compatibility promise.
*/
class Connection extends DoctrineConnection
{
/**
* Represents a SQL NULL data type.
*/
public const PARAM_NULL = ParameterType::NULL;
/**
* Represents a SQL INTEGER data type.
*/
public const PARAM_INT = ParameterType::INTEGER;
/**
* Represents a SQL CHAR, VARCHAR data type.
*/
public const PARAM_STR = ParameterType::STRING;
/**
* Represents a SQL large object data type.
*/
public const PARAM_LOB = ParameterType::LARGE_OBJECT;
/**
* Represents a boolean data type.
*/
public const PARAM_BOOL = ParameterType::BOOLEAN;
/**
* Represents an array of integer values.
*/
public const PARAM_INT_ARRAY = ArrayParameterType::INTEGER;
/**
* Represents an array of string values.
*/
public const PARAM_STR_ARRAY = ArrayParameterType::STRING;
private ExpressionBuilder $expressionBuilder;
private array $prepareConnectionCommands = [];
/**
* Initializes a new instance of the Connection class.
*
* @param array $params The connection parameters.
* @param Driver $driver The driver to use.
* @param DoctrineConfiguration|null $config The configuration, optional.
*/
public function __construct(#[\SensitiveParameter] array $params, Driver $driver, ?DoctrineConfiguration $config = null)
{
parent::__construct($params, $driver, $config);
if (!$config instanceof Configuration) {
throw new \InvalidArgumentException('TYPO3 Connection expects the custom TYPO3 Configuration object to be given', 1782369775);
}
$this->expressionBuilder = GeneralUtility::makeInstance(ExpressionBuilder::class, $this, $config->getContainer());
}
/**
* Gets the DatabasePlatform for the connection and initializes custom types and event listeners.
*/
protected function connect(): ConnectionInterface
{
if ($this->_conn !== null) {
return $this->_conn;
}
// Early return if the connection is already open and custom setup has been done.
$connection = parent::connect();
foreach ($this->prepareConnectionCommands as $command) {
$this->executeStatement($command);
}
return $connection;
}
/**
* Creates a new instance of a SQL query builder.
*/
public function createQueryBuilder(): QueryBuilder
{
return GeneralUtility::makeInstance(QueryBuilder::class, $this);
}
/**
* Quotes a string so it can be safely used as a table or column name, even if
* it is a reserved name.
* EXAMPLE: tableName.fieldName => "tableName"."fieldName"
*
* Delimiting style depends on the underlying database platform that is being used.
*
* Note that this does not call the parent implementation, because both
* Doctrine DBAL `Connection::quoteIdentifier()` and `AbstractPlatform::quoteIdentifier()`
* are deprecated and will be removed with Doctrine DBAL 5.0. The quoting is done
* here instead, identical to the removed implementation.
*
* @param string $identifier The name to be quoted.
* @return string The quoted name.
*/
public function quoteIdentifier(string $identifier): string
{
if ($identifier === '*') {
return $identifier;
}
$platform = $this->getDatabasePlatform();
if (!str_contains($identifier, '.')) {
return $platform->quoteSingleIdentifier($identifier);
}
return implode('.', array_map($platform->quoteSingleIdentifier(...), explode('.', $identifier)));
}
/**
* Quotes an array of column names, so it can be safely used, even if the name is a reserved name.
* Delimiting style depends on the underlying database platform that is being used.
*/
public function quoteIdentifiers(array $input): array
{
return array_map($this->quoteIdentifier(...), $input);
}
/**
* Quotes an associative array of column-value so the column names can be safely used, even
* if the name is a reserved name.
* Delimiting style depends on the underlying database platform that is being used.
*/
public function quoteColumnValuePairs(array $input): array
{
return array_combine($this->quoteIdentifiers(array_keys($input)), array_values($input));
}
/**
* Detect if the column types are specified by column name or using
* positional information. In the first case quote the field names
* accordingly.
*/
protected function quoteColumnTypes(array $input): array
{
if (!is_string(key($input))) {
return $input;
}
return $this->quoteColumnValuePairs($input);
}
/**
* Quotes like wildcards for given string value.
*
* @param string $value The value to be quoted.
* @return string The quoted value.
*/
public function escapeLikeWildcards(string $value): string
{
return addcslashes($value, '_%');
}
/**
* Inserts a table row with specified data.
*
* All SQL identifiers are expected to be unquoted and will be quoted when building the query.
*
* @param string $tableName The name of the table to insert data into.
* @param array $data An associative array containing column-value pairs.
* @param array $types Types of the inserted data.
* @return int The number of affected rows.
* @throws Exception
*/
public function insert(string $tableName, array $data, array $types = []): int
{
$this->ensureDatabaseValueTypes($tableName, $data, $types);
return parent::insert(
$this->quoteIdentifier($tableName),
$this->quoteColumnValuePairs($data),
$this->quoteColumnTypes($types)
);
}
/**
* Bulk inserts table rows with specified data.
* All SQL identifiers are expected to be unquoted and will be quoted when building the query.
*
* @param string $tableName The name of the table to insert data into.
* @param array $data An array containing associative arrays of column-value pairs or just the values to be inserted.
* @param array $columns An array containing the column names of the data which should be inserted.
* @param array $types Types of the inserted data.
* @return int The number of affected rows.
*/
public function bulkInsert(string $tableName, array $data, array $columns = [], array $types = []): int
{
$totalAffectedRows = 0;
$columnLength = $columns !== [] ? count($columns) : 1000;
$maxBindParameters = PlatformInformation::getMaxBindParameters($this->getDatabasePlatform());
$maxChunkSize = (int)(($maxBindParameters / $columnLength) / 2);
$chunks = array_chunk($data, $maxChunkSize);
foreach ($chunks as $chunk) {
$query = GeneralUtility::makeInstance(BulkInsertQuery::class, $this, $tableName, $columns);
foreach ($chunk as $values) {
$this->ensureDatabaseValueTypes($tableName, $values, $types);
$query->addValues($values, $types);
}
$totalAffectedRows += $query->execute();
}
return $totalAffectedRows;
}
/**
* Executes an SQL SELECT statement on a table.
* All SQL identifiers are expected to be unquoted and will be quoted when building the query.
*
* @param string[] $columns The columns of the table which to select.
* @param string $tableName The name of the table on which to select.
* @param array $identifiers The selection criteria. An associative array containing column-value pairs.
* @param string[] $groupBy The columns to group the results by.
* @param array $orderBy Associative array of column name/sort directions pairs.
* @param int $limit The maximum number of rows to return.
* @param int $offset The first result row to select (when used with limit)
* @return Result The executed statement.
*/
public function select(
array $columns,
string $tableName,
array $identifiers = [],
array $groupBy = [],
array $orderBy = [],
int $limit = 0,
int $offset = 0
) {
$query = $this->createQueryBuilder();
$query->select(...$columns)->from($tableName);
foreach ($identifiers as $identifier => $value) {
$query->andWhere($query->expr()->eq($identifier, $query->createNamedParameter($value)));
}
foreach ($orderBy as $fieldName => $order) {
$query->addOrderBy($fieldName, $order);
}
if (!empty($groupBy)) {
$query->groupBy(...$groupBy);
}
if ($limit > 0) {
$query->setMaxResults($limit);
$query->setFirstResult($offset);
}
return $query->executeQuery();
}
/**
* Executes an SQL UPDATE statement on a table.
* All SQL identifiers are expected to be unquoted and will be quoted when building the query.
*
* @param string $tableName The name of the table to update.
* @param array $data An associative array containing column-value pairs.
* @param array $identifier The update criteria. An associative array containing column-value pairs.
* @param array $types Types of the merged $data and $identifier arrays in that order.
* @return int The number of affected rows.
* @throws Exception
*/
public function update(string $tableName, array $data, array $identifier = [], array $types = []): int
{
$this->ensureDatabaseValueTypes($tableName, $data, $types);
return parent::update(
$this->quoteIdentifier($tableName),
$this->quoteColumnValuePairs($data),
$this->quoteColumnValuePairs($identifier),
$this->quoteColumnTypes($types)
);
}
/**
* Executes an SQL DELETE statement on a table.
* All SQL identifiers are expected to be unquoted and will be quoted when building the query.
*
* @param string $tableName The name of the table on which to delete.
* @param array $identifier The deletion criteria. An associative array containing column-value pairs.
* @param array $types The types of identifiers.
* @return int The number of affected rows.
*/
public function delete(string $tableName, array $identifier = [], array $types = []): int
{
return parent::delete(
$this->quoteIdentifier($tableName),
$this->quoteColumnValuePairs($identifier),
$this->quoteColumnTypes($types)
);
}
/**
* Executes an SQL TRUNCATE statement on a table.
* All SQL identifiers are expected to be unquoted and will be quoted when building the query.
*
* @param string $tableName The name of the table to truncate.
* @param bool $cascade Not supported on many platforms but would cascade the truncate by following foreign keys.
* @return int The number of affected rows. For a truncate this is unreliable as there is no meaningful information.
*/
public function truncate(string $tableName, bool $cascade = false): int
{
return $this->executeStatement(
$this->getDatabasePlatform()->getTruncateTableSQL(
$this->quoteIdentifier($tableName),
$cascade
)
);
}
/**
* Executes an SQL SELECT COUNT() statement on a table and returns the count result.
*
* @param string $item The column/expression of the table which to count
* @param string $tableName The name of the table on which to count.
* @param array $identifiers The selection criteria. An associative array containing column-value pairs.
* @return int The number of rows counted
*/
public function count(string $item, string $tableName, array $identifiers): int
{
$query = $this->createQueryBuilder();
$query->count($item)->from($tableName);
foreach ($identifiers as $identifier => $value) {
$query->andWhere($query->expr()->eq($identifier, $query->createNamedParameter($value)));
}
return (int)$query->executeQuery()->fetchOne();
}
/**
* Returns the version of the current platform if applicable, containing the platform as prefix.
*
* If no version information is available only the platform name will be shown.
* If the platform name is unknown or unsupported the driver name will be shown.
*
* @internal only and not part of public API.
*/
public function getPlatformServerVersion(): string
{
$platform = $this->getDatabasePlatform();
$version = trim($this->typo3_getServerVersionProvider()->getServerVersion());
if ($version !== '') {
$version = ' ' . $version;
}
return match (true) {
// @todo Check if we should use 'MariaDB' now directly instead of MySQL as an alias.
$platform instanceof DoctrineMariaDBPlatform => 'MySQL' . $version,
$platform instanceof DoctrineMySQLPlatform => 'MySQL' . $version,
$platform instanceof DoctrinePostgreSQLPlatform => 'PostgreSQL' . $version,
default => (str_replace('Platform', '', array_reverse(explode('\\', $platform::class))[0])) . $version,
};
}
/**
* Execute commands after initializing a new connection.
*/
public function prepareConnection(string $commands): void
{
if (empty($commands)) {
return;
}
$this->prepareConnectionCommands = GeneralUtility::trimExplode(
LF,
str_replace(
'\' . LF . \'',
LF,
$commands
),
true
);
}
/**
* Returns the ID of the last inserted row.
* If the underlying driver does not support identity columns, an exception is thrown.
*
* @return numeric-string
*/
public function lastInsertId(): string
{
return (string)parent::lastInsertId();
}
/**
* Gets the ExpressionBuilder for the connection.
*/
public function getExpressionBuilder(): ExpressionBuilder
{
return $this->expressionBuilder;
}
/**
* This method ensures that data values a properly converted to their database equivalent.
* Additionally, it adds the proper types to the type-array, if this has no manual preset types.
* Note: Types are *only* added if not given externally.
*
* @internal Should be private, but mocked in tests currently.
*/
protected function ensureDatabaseValueTypes(string $tableName, array &$data, array &$types): void
{
$tableInfo = $this->getSchemaInformation()->getTableInfo($tableName);
array_walk($data, function (mixed &$value, string $key) use ($tableInfo, &$types): void {
// Use database schema field type in case no Type or ParameterType has been provided manually
// for field `$key`, falling back to ParameterType::STRING in case field does not exists in
// the schema, which is the default ParameterType used by doctrine anyway.
if (!isset($types[$key]) && $tableInfo->hasColumnInfo($key)) {
$types[$key] = $tableInfo->getColumnInfo($key)->getType();
}
});
}
/**
* @internal May vanish anytime, currently used core-internal at some places.
*/
public function getSchemaInformation(): SchemaInformation
{
return new SchemaInformation(
$this,
GeneralUtility::makeInstance(CacheManager::class)->getCache('runtime'),
GeneralUtility::makeInstance(CacheManager::class)->getCache('database_schema'),
GeneralUtility::makeInstance(PackageDependentCacheIdentifier::class),
);
}
/**
* Executes a function in a transaction.
*
* The function gets passed this Connection instance as an (optional) parameter.
*
* If an exception occurs during execution of the function or transaction commit,
* the transaction is rolled back and the exception re-thrown.
*
* @param \Closure(self):T $func The function to execute transactionally.
* @return T The value returned by $func
* @throws \Throwable
* @template T
*/
public function transactional(\Closure $func): mixed
{
/** @var \Closure(DoctrineConnection):T $func Required to satisfy PHPStan. */
return parent::transactional($func);
}
/**
* Returns the suitable `ServerVersionProvider`, which could be the connection itself or
* a `StaticServerVersionProvider` based on either of following configuration values:
*
* - $params['serverVersion']
* - $params['primary']['serverVersion']
*
* This is an extract from {@see \Doctrine\DBAL\Connection::getDatabasePlatform()} and handled as internal for
* now and will be tried to provide upstream making it API and is the reason why it is a prefixed method.
*
* It's currently only used in internal {@see self::getPlatformServerVersion()}.
*
* @internal only and not part of public API.
*/
protected function typo3_getServerVersionProvider(): ServerVersionProvider
{
$params = $this->getParams();
return match (true) {
isset($params['serverVersion']) => new StaticServerVersionProvider($params['serverVersion']),
isset($params['primary']['serverVersion']) => new StaticServerVersionProvider($params['primary']['serverVersion']),
default => $this,
};
}
}