OpenXE/classes/Components/Database/Adapter/MysqliAdapter.php
2021-05-21 08:49:41 +02:00

884 lines
23 KiB
PHP

<?php
namespace Xentral\Components\Database\Adapter;
use Generator;
use mysqli;
use mysqli_result;
use mysqli_stmt;
use Xentral\Components\Database\DatabaseConfig;
use Xentral\Components\Database\Exception\BindParameterException;
use Xentral\Components\Database\Exception\ConnectionException;
use Xentral\Components\Database\Exception\EscapingException;
use Xentral\Components\Database\Exception\QueryFailureException;
use Xentral\Components\Database\Exception\TransactionException;
use Xentral\Components\Database\Parser\MysqliArrayValueParser;
use Xentral\Components\Database\Parser\MysqliNamedParameterParser;
use Xentral\Components\Database\Profiler\ProfilerInterface;
final class MysqliAdapter implements AdapterInterface
{
/** @var mysqli|null $connection */
private $connection;
/** @var DatabaseConfig $config */
private $config;
/** @var bool $transactionActive */
private $transactionActive = false;
/** @var int|null $reconnectCounter */
private $reconnectCounter;
/** @var int $reconnectLimit */
private $reconnectMaxCount = 5;
/** @var ProfilerInterface|null $profiler */
private $profiler;
/**
* @param DatabaseConfig $config
* @param ProfilerInterface|null $profiler
*/
public function __construct(DatabaseConfig $config, ProfilerInterface $profiler = null)
{
$this->config = $config;
$this->profiler = $profiler;
}
/**
* @throws ConnectionException
*
* @return void
*/
public function connect()
{
if ($this->connection !== null) {
return;
}
if ($this->reconnectCounter === null) {
$this->reconnectCounter = 0;
} else {
$this->reconnectCounter++;
}
if ($this->reconnectCounter >= $this->reconnectMaxCount) {
throw new ConnectionException(sprintf(
'Too many reconnects. Reconnect count: %d (Max allowed %d)',
$this->reconnectCounter,
$this->reconnectMaxCount
));
}
$this->startProfiler(__FUNCTION__);
$connection = new mysqli(
$this->config->getHostname(),
$this->config->getUsername(),
$this->config->getPassword(),
null,
$this->config->getPort()
);
if ($connection->connect_errno > 0) {
throw new ConnectionException(sprintf(
'Database connection to host "%s" failed. Error code #%s. Error message: %s',
$this->config->getHostname(),
$connection->connect_errno,
$connection->connect_error
));
}
$connection->select_db($this->config->getDatabase());
if ($connection->errno > 0) {
throw new ConnectionException(sprintf(
'Database selection failed for database "%s". Error code #%s. Error message: %s',
$this->config->getDatabase(),
$connection->errno,
$connection->error
));
}
// @see https://www.php.net/manual/de/mysqlinfo.concepts.charset.php
if (!$connection->set_charset($this->config->getCharset())) {
throw new ConnectionException(sprintf(
'Failed to set character set "%s". Error: %s',
$this->config->getCharset(),
$connection->error
));
}
if (!$connection->autocommit(true)) {
throw new ConnectionException(sprintf(
'Failed to activate auto commit. Error: %s',
$connection->error
));
}
$this->finishProfiler(null, [
'dbname' => $this->config->getDatabase(),
'host' => $this->config->getHostname(),
'port' => $this->config->getPort(),
]);
$this->connection = $connection;
foreach ($this->config->getQueries() as $query) {
$this->perform($query);
}
}
/**
* @return void
*/
public function disconnect()
{
if ($this->connection === null) {
return;
}
$this->startProfiler(__FUNCTION__);
$this->connection->close();
$this->connection = null;
$this->finishProfiler();
}
/**
* @return bool
*/
public function inTransaction()
{
return $this->transactionActive;
}
/**
* @throws TransactionException If transaction is already started
*
* @return void
*/
public function beginTransaction()
{
if ($this->inTransaction()) {
throw new TransactionException('Transaction is already started.');
}
$this->connect();
if ($this->connection->begin_transaction() === false) {
throw new TransactionException(sprintf('Transaction start failed: %s', $this->connection->error));
}
$this->transactionActive = true;
}
/**
* @throws TransactionException
*
* @return void
*/
public function commit()
{
if (!$this->inTransaction()) {
throw new TransactionException('Transaction not started.');
}
$this->connection->commit();
$this->connection->autocommit(true);
$this->transactionActive = false;
}
/**
* @throws TransactionException
*
* @return void
*/
public function rollback()
{
if (!$this->inTransaction()) {
throw new TransactionException('Transaction not started.');
}
$this->connection->rollback();
$this->connection->autocommit(true);
$this->transactionActive = false;
}
/**
* @return int
*/
public function lastInsertId()
{
return (int)$this->connection->insert_id;
}
/**
* @param array $values
* @param string $statement
*
* @throws QueryFailureException
*
* @return void
*/
public function perform($statement, array $values = [])
{
$this->connect();
$this->startProfiler(__FUNCTION__);
$query = $this->getMysqliStatement($statement, $values);
if (!is_object($query)) {
throw new QueryFailureException(sprintf('Database query failed: %s', $this->connection->error));
}
$query->close();
$this->finishProfiler($statement, $values);
}
/**
* @param string $statement
* @param array $values
*
* @throws QueryFailureException
*
* @return int
*/
public function fetchAffected($statement, array $values = [])
{
$this->connect();
$this->startProfiler(__FUNCTION__);
$query = $this->getMysqliStatement($statement, $values);
if (!is_object($query)) {
throw new QueryFailureException(sprintf('Database query failed: %s', $this->connection->error));
}
$affectedRows = (int)$query->affected_rows;
$query->close();
$this->finishProfiler($statement, $values);
return $affectedRows;
}
/**
* @param string $statement
* @param array $values
*
* @return array
*/
public function fetchAll($statement, array $values = [])
{
$this->connect();
$this->startProfiler(__FUNCTION__);
$result = $this->getMysqliResult($statement, $values);
$data = [];
while ($row = $result->fetch_assoc()) {
$data[] = $row;
}
$result->close();
$this->finishProfiler($statement, $values);
return $data;
}
/**
* @param string $statement
* @param array $values
*
* @return array
*/
public function fetchAssoc($statement, array $values = [])
{
$this->connect();
$this->startProfiler(__FUNCTION__);
$result = $this->getMysqliResult($statement, $values);
$data = [];
while ($row = $result->fetch_assoc()) {
$assocKey = reset($row); // Fetch first array value
$data[$assocKey] = $row;
}
$result->close();
$this->finishProfiler($statement, $values);
return $data;
}
/**
* @param string $statement
* @param array $values
*
* @return array Empty array on empty result
*/
public function fetchRow($statement, array $values = [])
{
$this->connect();
$this->startProfiler(__FUNCTION__);
$result = $this->getMysqliResult($statement, $values);
if ($result->num_rows === 0) {
$result->close();
return [];
}
$data = $result->fetch_assoc();
$result->close();
$this->finishProfiler($statement, $values);
return $data;
}
/**
* @param string $statement
* @param array $values
*
* @return int|float|string|false false on empty result
*/
public function fetchValue($statement, array $values = [])
{
$this->connect();
$this->startProfiler(__FUNCTION__);
$result = $this->getMysqliResult($statement, $values);
if ($result->num_rows === 0) {
$result->close();
return false;
}
$data = $result->fetch_assoc();
$result->close();
$this->finishProfiler($statement, $values);
return reset($data);
}
/**
* @param string $statement
* @param array $values
*
* @return array
*/
public function fetchCol($statement, array $values = [])
{
$this->connect();
$this->startProfiler(__FUNCTION__);
$result = $this->getMysqliResult($statement, $values);
$data = [];
while ($row = $result->fetch_assoc()) {
$firstValue = reset($row); // Fetch first array value
$data[] = $firstValue;
}
$result->close();
$this->finishProfiler($statement, $values);
return $data;
}
/**
* @param string $statement
* @param array $values
*
* @throws QueryFailureException
*
* @return array
*/
public function fetchPairs($statement, array $values = [])
{
$this->connect();
$this->startProfiler(__FUNCTION__);
$result = $this->getMysqliResult($statement, $values);
if ($result->field_count !== 2) {
throw new QueryFailureException('Field count does not match. fetchPairs() allows only two fields.');
}
$data = [];
while ($row = $result->fetch_assoc()) {
$key = array_shift($row);
$value = array_shift($row);
$data[$key] = $value;
}
$result->close();
$this->finishProfiler($statement, $values);
return $data;
}
/**
* @param string $statement
* @param array $values
* @param bool $includeGroupColumn
*
* @return array
*/
public function fetchGroup($statement, array $values = [], $includeGroupColumn = false)
{
$this->connect();
$this->startProfiler(__FUNCTION__);
$result = $this->getMysqliResult($statement, $values);
$data = [];
$includeGroupColumn = (bool)$includeGroupColumn;
while ($row = $result->fetch_assoc()) {
$group = $includeGroupColumn === true ? reset($row) : array_shift($row); // Fetch first array value
if (!isset($data[$group])) {
$data[$group] = [];
}
$data[$group][] = $row;
}
$result->close();
$this->finishProfiler($statement, $values);
return $data;
}
/**
* @param string $statement
* @param array $values
*
* @return Generator
*/
public function yieldAll($statement, array $values = [])
{
$this->connect();
$this->startProfiler(__FUNCTION__);
$result = $this->getMysqliResult($statement, $values);
$this->finishProfiler($statement, $values);
while ($row = $result->fetch_assoc()) {
yield $row;
}
$result->close();
}
/**
* @param string $statement
* @param array $values
*
* @return Generator
*/
public function yieldAssoc($statement, array $values = [])
{
$this->connect();
$this->startProfiler(__FUNCTION__);
$result = $this->getMysqliResult($statement, $values);
$this->finishProfiler($statement, $values);
while ($row = $result->fetch_assoc()) {
$assocKey = reset($row); // Fetch first array value
yield $assocKey => $row;
}
$result->close();
}
/**
* @param string $statement
* @param array $values
*
* @return Generator
*/
public function yieldCol($statement, array $values = [])
{
$this->connect();
$this->startProfiler(__FUNCTION__);
$result = $this->getMysqliResult($statement, $values);
$this->finishProfiler($statement, $values);
while ($row = $result->fetch_assoc()) {
$firstValue = reset($row); // Fetch first array value
yield $firstValue;
}
$result->close();
}
/**
* @param string $statement
* @param array $values
*
* @throws QueryFailureException
*
* @return Generator
*/
public function yieldPairs($statement, array $values = [])
{
$this->connect();
$this->startProfiler(__FUNCTION__);
$result = $this->getMysqliResult($statement, $values);
$this->finishProfiler($statement, $values);
if ($result->field_count !== 2) {
throw new QueryFailureException('Field count does not match. yieldPairs() allows only two fields.');
}
while ($row = $result->fetch_assoc()) {
$key = array_shift($row);
$value = array_shift($row);
yield $key => $value;
}
$result->close();
}
/**
* Escapes values for "BOOLEAN" and (TINY)INT columns
*
* @param bool|null $value
* @param bool $isNullable
*
* @throws EscapingException
*
* @return string
*/
public function escapeBool($value, $isNullable = false)
{
if ($isNullable === true && $value === null) {
return 'NULL';
}
if (!is_bool($value)) {
throw new EscapingException('Can not escape bool. Value is not a bool.');
}
return $value === true ? '1' : '0';
}
/**
* Escapes values for INT columns
*
* @param mixed $value
* @param bool $isNullable
*
* @throws EscapingException
*
* @return string
*/
public function escapeInt($value, $isNullable = false)
{
if ($isNullable === true && $value === null) {
return 'NULL';
}
if (!is_int($value)) {
throw new EscapingException('Can not escape integer. Value is not an integer.');
}
return (string)(int)$value;
}
/**
* Escapes values for FLOAT, DOUBLE and REAL columns
*
* @param mixed $value
* @param bool $isNullable
*
* @throws EscapingException
*
* @return string
*/
public function escapeDecimal($value, $isNullable = false)
{
if ($isNullable === true && $value === null) {
return 'NULL';
}
if (!is_numeric($value)) {
throw new EscapingException('Can not escape decimal. Value is not numeric.');
}
return (string)$value;
}
/**
* Escapes values for CHAR, VARCHAR, TEXT and BLOB columns
*
* @param mixed $value
* @param bool $isNullable
*
* @throws EscapingException
*
* @return string
*/
public function escapeString($value, $isNullable = false)
{
if ($isNullable === true && $value === null) {
return 'NULL';
}
if (!is_string($value)) {
throw new EscapingException('Can not escape string. Value is not a string.');
}
$this->connect();
return "'" . $this->connection->real_escape_string($value) . "'";
}
/**
* Escapes and quotes an identifier (column or table name)
*
* @param string $value
*
* @throws EscapingException
*
* @return string
*/
public function escapeIdentifier($value)
{
if (!is_string($value)) {
throw new EscapingException('Can not escape identifier. Passed value is not a string.');
}
if (empty(trim($value))) {
throw new EscapingException('Can not escape identifier. Passed value is empty.');
}
$parts = explode('.', $value);
if (count($parts) > 2) {
throw new EscapingException('Can not escape identifier. Identifier contains more than one dots.');
}
$partsCleaned = [];
foreach ($parts as $part) {
if (empty(trim($part))) {
throw new EscapingException(
'Can not escape identifier. Parts before and after the dot can not be empty.'
);
}
if (strlen($part) > 64) {
throw new EscapingException(
'Can not escape identifier. Identifier is too long. Only 64 characters are allowed.'
);
}
$partCleaned = preg_replace('/[^A-Za-z0-9_]+/', '', $part);
if (strlen($partCleaned) !== strlen($part)) {
throw new EscapingException(
'Can not escape identifier. Passed value contains invalid characters. ' .
'Valid characters: A-Z, a-z, 0-9, Underscore'
);
}
$partsCleaned[] = '`' . $partCleaned . '`';
}
return implode('.', $partsCleaned);
}
/**
* @return void
*/
public function __clone()
{
$this->connection = null;
$this->transactionActive = false;
$this->config = clone $this->config;
}
/**
* @return void
*/
public function __destruct()
{
$this->disconnect();
}
/**
* @return void
*/
public function __wakeup()
{
}
/**
* @return array
*/
public function __sleep()
{
return [];
}
/**
* @param string $statement
* @param array $values
*
* @return mysqli_stmt
*/
private function getMysqliStatement($statement, array $values = [])
{
list($statement, $values) = $this->replaceArrayValues($statement, $values);
list($rebuildStatement, $bindValues, $parameterNames) = $this->replaceNamedParameters($statement, $values);
$query = $this->connection->prepare($rebuildStatement);
if ($query === false && $this->connection->errno === 2006) {
// Code 2006 = MySQL server has gone away
// Falls Verbindung in einen Timeout gelaufen ist
// => Verbindung wiederherstellen und Prepare erneut probieren
$this->disconnect();
$this->connect();
$query = $this->connection->prepare($rebuildStatement);
}
if ($query === false || !is_object($query)) {
throw new QueryFailureException(
sprintf(
'Database prepare failed. Error code #%s. Error message: %s',
$this->connection->errno,
$this->connection->error
),
(int)$this->connection->errno
);
}
$this->bindParametersToMysqliStatement($query, $bindValues, $parameterNames);
$query->execute();
if ($query->errno > 0) {
throw new QueryFailureException(
sprintf('Database query failed. Error code #%s. Error message: %s', $query->errno, $query->error),
(int)$query->errno
);
}
return $query;
}
/**
* @param string $statement
* @param array $values
*
* @return array
*/
private function replaceArrayValues($statement, array $values = [])
{
$parser = new MysqliArrayValueParser();
$result = $parser->rebuild($statement, $values);
return [$result['statement'], $result['values']];
}
/**
* @param string $statement
* @param array $values
*
* @return array
*/
private function replaceNamedParameters($statement, array $values = [])
{
$parser = new MysqliNamedParameterParser();
$result = $parser->rebuild($statement, $values);
return [$result['statement'], $result['values'], $result['params']];
}
/**
* @param mysqli_stmt $statement
* @param array $bindValues Values for binding
* @param array $parameterNames Original parameter names (for debugging only)
*
* @return void
*/
private function bindParametersToMysqliStatement($statement, $bindValues, $parameterNames)
{
if (empty($bindValues)) {
return;
}
$bindTypes = '';
foreach ($bindValues as $index => &$bindValue) {
if (is_bool($bindValue)) {
$bindValue = (int)$bindValue;
$bindTypes .= 'i'; // integer
continue;
}
if (is_float($bindValue)) {
$bindTypes .= 'd'; // double
continue;
}
if (is_array($bindValue)) {
throw new BindParameterException(sprintf(
'Can not bind parameter of type "array" to placeholder "%s".',
$parameterNames[$index]
));
}
if (is_object($bindValue)) {
throw new BindParameterException(sprintf(
'Can not bind parameter of type "object" to placeholder "%s".',
$parameterNames[$index]
));
}
$bindTypes .= 's'; // string
}
unset($bindValue);
$statement->bind_param($bindTypes, ...$bindValues);
}
/**
* @param $statement
* @param array $values
*
* @return mysqli_result
*/
private function getMysqliResult($statement, array $values = [])
{
$query = $this->getMysqliStatement($statement, $values);
$result = $query->get_result();
$query->close();
if ($result === false) {
throw new QueryFailureException(
sprintf('Database query failed. Error code #%s. Error message: %s', $query->errno, $query->error),
(int)$query->errno
);
}
return $result;
}
/**
* @param string $methodName
*
* @return void
*/
private function startProfiler($methodName)
{
if ($this->profiler === null) {
return;
}
$this->profiler->start(__CLASS__, $methodName);
}
/**
* @param string|null $statement
* @param array $values
*
* @return void
*/
private function finishProfiler($statement = null, array $values = [])
{
if ($this->profiler === null) {
return;
}
$this->profiler->finish($statement, $values);
}
}