474 lines
13 KiB
PHP
474 lines
13 KiB
PHP
<?php
|
||
/**
|
||
* This file is part of the ForkBB <https://github.com/forkbb>.
|
||
*
|
||
* @copyright (c) Visman <mio.visman@yandex.ru, https://github.com/MioVisman>
|
||
* @license The MIT License (MIT)
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace ForkBB\Core;
|
||
|
||
use ForkBB\Core\DB\DBStatement;
|
||
use PDO;
|
||
use PDOStatement;
|
||
use PDOException;
|
||
use SensitiveParameter;
|
||
|
||
class DB
|
||
{
|
||
/**
|
||
* Экземпляр PDO через который идет работа с бд
|
||
*/
|
||
protected PDO $pdo;
|
||
|
||
/**
|
||
* Префикс для таблиц базы
|
||
*/
|
||
protected string $dbPrefix;
|
||
|
||
/**
|
||
* Тип базы данных
|
||
*/
|
||
protected string $dbType;
|
||
|
||
/**
|
||
* Имя класса для драйвера
|
||
*/
|
||
protected string $dbDrvClass;
|
||
|
||
/**
|
||
* Драйвер текущей базы
|
||
* @var //????
|
||
*/
|
||
protected $dbDrv;
|
||
|
||
/**
|
||
* Имя класса для PDOStatement
|
||
*/
|
||
protected string $statementClass;
|
||
|
||
/**
|
||
* Количество выполненных запросов
|
||
*/
|
||
protected int $qCount = 0;
|
||
|
||
/**
|
||
* Выполненные запросы
|
||
*/
|
||
protected array $queries = [];
|
||
|
||
/**
|
||
* Дельта времени для следующего запроса
|
||
*/
|
||
protected float $delta = 0;
|
||
|
||
protected array $pdoMethods = [
|
||
'beginTransaction' => true,
|
||
'commit' => true,
|
||
'errorCode' => true,
|
||
'errorInfo' => true,
|
||
'exec' => true,
|
||
'getAttribute' => true,
|
||
'getAvailableDrivers' => true,
|
||
'inTransaction' => true,
|
||
'lastInsertId' => true,
|
||
'prepare' => true,
|
||
'query' => true,
|
||
'quote' => true,
|
||
'rollBack' => true,
|
||
'setAttribute' => true,
|
||
|
||
'pgsqlCopyFromArray' => true,
|
||
'pgsqlCopyFromFile' => true,
|
||
'pgsqlCopyToArray' => true,
|
||
'pgsqlCopyToFile' => true,
|
||
'pgsqlGetNotify' => true,
|
||
'pgsqlGetPid' => true,
|
||
'pgsqlLOBCreate' => true,
|
||
'pgsqlLOBOpen' => true,
|
||
'pgsqlLOBUnlink' => true,
|
||
|
||
'sqliteCreateAggregate' => true,
|
||
'sqliteCreateCollation' => true,
|
||
'sqliteCreateFunction' => true,
|
||
];
|
||
|
||
public function __construct(
|
||
string $dsn,
|
||
string $username = null,
|
||
#[SensitiveParameter] string $password = null,
|
||
array $options = [],
|
||
string $prefix = ''
|
||
) {
|
||
$dsn = $this->initialConfig($dsn);
|
||
|
||
if (\preg_match('%[^\w]%', $prefix)) {
|
||
throw new PDOException("Bad prefix");
|
||
}
|
||
|
||
$this->dbPrefix = $prefix;
|
||
|
||
list($initSQLCommands, $initFunction) = $this->prepareOptions($options);
|
||
|
||
$start = \microtime(true);
|
||
$this->pdo = new PDO($dsn, $username, $password, $options);
|
||
|
||
$this->saveQuery('PDO::__construct()', \microtime(true) - $start, false);
|
||
|
||
if (\is_string($initSQLCommands)) {
|
||
$this->exec($initSQLCommands);
|
||
}
|
||
|
||
if (
|
||
null !== $initFunction
|
||
&& true !== $initFunction($this)
|
||
) {
|
||
throw new PDOException("initFunction failure");
|
||
}
|
||
|
||
$this->beginTransaction();
|
||
}
|
||
|
||
protected function initialConfig(string $dsn): string
|
||
{
|
||
$type = \strstr($dsn, ':', true);
|
||
|
||
if (! \in_array($type, PDO::getAvailableDrivers(), true)) {
|
||
throw new PDOException("PDO does not have driver for '{$type}'");
|
||
}
|
||
|
||
$typeU = \ucfirst($type);
|
||
|
||
if (! \is_file(__DIR__ . "/DB/{$typeU}.php")) {
|
||
throw new PDOException("Driver isn't found for '$type'");
|
||
}
|
||
|
||
$this->dbType = $type;
|
||
$this->dbDrvClass = "ForkBB\\Core\\DB\\{$typeU}";
|
||
|
||
if (\is_file(__DIR__ . "/DB/{$typeU}Statement.php")) {
|
||
$this->statementClass = "ForkBB\\Core\\DB\\{$typeU}Statement";
|
||
} else {
|
||
$this->statementClass = DBStatement::class;
|
||
}
|
||
|
||
if ('sqlite' === $type) {
|
||
$dsn = \str_replace('!PATH!', \realpath(__DIR__ . '/../config/db') . '/', $dsn);
|
||
}
|
||
|
||
return $dsn;
|
||
}
|
||
|
||
protected function prepareOptions(array &$options): array
|
||
{
|
||
$result = [
|
||
0 => null,
|
||
1 => null,
|
||
];
|
||
|
||
if (isset($options['initSQLCommands'])) {
|
||
$result[0] = \implode(';', $options['initSQLCommands']);
|
||
|
||
unset($options['initSQLCommands']);
|
||
}
|
||
|
||
if (isset($options['initFunction'])) {
|
||
$result[1] = $options['initFunction'];
|
||
|
||
unset($options['initFunction']);
|
||
}
|
||
|
||
$options += [
|
||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||
PDO::ATTR_EMULATE_PREPARES => false,
|
||
PDO::ATTR_STRINGIFY_FETCHES => false,
|
||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||
];
|
||
|
||
return $result;
|
||
}
|
||
|
||
protected function dbStatement(PDOStatement $stmt): DBStatement
|
||
{
|
||
return new $this->statementClass($this, $stmt);
|
||
}
|
||
|
||
/**
|
||
* Метод приводит запрос с типизированными плейсхолдерами к понятному для PDO виду
|
||
*/
|
||
protected function parse(string &$query, array $params): array
|
||
{
|
||
$idxIn = 0;
|
||
$idxOut = 1;
|
||
$map = [];
|
||
$query = \preg_replace_callback(
|
||
'%(?=[?:])(?<![\w?:])(?:::(\w+)|\?(?![?:])(?:(\w+)(?::(\w+))?)?|:(\w+))%',
|
||
function ($matches) use ($params, &$idxIn, &$idxOut, &$map) {
|
||
if (! empty($matches[1])) {
|
||
return $this->dbPrefix . $matches[1];
|
||
}
|
||
|
||
$type = empty($matches[2]) ? 's' : $matches[2];
|
||
$key = $matches[4] ?? ($matches[3] ?? $idxIn++);
|
||
$value = $this->getValue($key, $params);
|
||
|
||
switch ($type) {
|
||
case 'p':
|
||
return (string) $value;
|
||
case 'ai':
|
||
case 'as':
|
||
case 'a':
|
||
break;
|
||
case 'i':
|
||
case 'b':
|
||
case 's':
|
||
case 'f':
|
||
$value = [1];
|
||
break;
|
||
default:
|
||
$value = [1];
|
||
$type = 's';
|
||
break;
|
||
}
|
||
|
||
if (! \is_array($value)) {
|
||
throw new PDOException("Expected array: key='{$key}', type='{$type}'");
|
||
}
|
||
|
||
$map[$key] ??= [$type];
|
||
$res = [];
|
||
|
||
foreach ($value as $val) {
|
||
$name = ':' . $idxOut++;
|
||
$res[] = $name;
|
||
$map[$key][] = $name;
|
||
}
|
||
|
||
return \implode(',', $res);
|
||
},
|
||
$query
|
||
);
|
||
|
||
return $map;
|
||
}
|
||
|
||
/**
|
||
* Метод возвращает значение из массива параметров по ключу или исключение
|
||
*/
|
||
public function getValue(int|string $key, array $params): mixed
|
||
{
|
||
if (
|
||
\is_string($key)
|
||
&& \array_key_exists(':' . $key, $params)
|
||
) {
|
||
return $params[':' . $key];
|
||
} elseif (\array_key_exists($key, $params)) {
|
||
return $params[$key];
|
||
}
|
||
|
||
throw new PDOException("The '{$key}' key is not found in the parameters");
|
||
}
|
||
|
||
/**
|
||
* Метод для получения количества выполненных запросов
|
||
*/
|
||
public function getCount(): int
|
||
{
|
||
return $this->qCount;
|
||
}
|
||
|
||
/**
|
||
* Метод для получения статистики выполненных запросов
|
||
*/
|
||
public function getQueries(): array
|
||
{
|
||
return $this->queries;
|
||
}
|
||
|
||
/**
|
||
* Возвращает тип базы данных указанный в DSN
|
||
*/
|
||
public function getType(): string
|
||
{
|
||
return $this->dbType;
|
||
}
|
||
|
||
/**
|
||
* Метод для сохранения статистики по выполненному запросу
|
||
*/
|
||
public function saveQuery(string $query, float $time, bool $add = true): void
|
||
{
|
||
if ($add) {
|
||
++$this->qCount;
|
||
}
|
||
|
||
$this->queries[] = [$query, $time + $this->delta];
|
||
$this->delta = 0;
|
||
}
|
||
|
||
/**
|
||
* Метод расширяет PDO::exec()
|
||
*/
|
||
public function exec(string $query, array $params = []): int|false
|
||
{
|
||
$map = $this->parse($query, $params);
|
||
|
||
if (empty($params)) {
|
||
$start = \microtime(true);
|
||
$result = $this->pdo->exec($query);
|
||
|
||
$this->saveQuery($query, \microtime(true) - $start);
|
||
|
||
return $result;
|
||
}
|
||
|
||
$start = \microtime(true);
|
||
$stmt = $this->pdo->prepare($query);
|
||
$this->delta = \microtime(true) - $start;
|
||
|
||
if (! $stmt instanceof PDOStatement) {
|
||
return false;
|
||
}
|
||
|
||
$stmt = $this->dbStatement($stmt);
|
||
|
||
$stmt->setMap($map);
|
||
|
||
if ($stmt->execute($params)) {
|
||
return $stmt->rowCount(); //??? Для запроса SELECT... не ясно поведение!
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Метод расширяет PDO::prepare()
|
||
*/
|
||
public function prepare(string $query, array $params = [], array $options = []): DBStatement|false
|
||
{
|
||
$map = $this->parse($query, $params);
|
||
$start = \microtime(true);
|
||
$stmt = $this->pdo->prepare($query, $options);
|
||
$this->delta = \microtime(true) - $start;
|
||
|
||
if (! $stmt instanceof PDOStatement) {
|
||
return false;
|
||
}
|
||
|
||
$stmt = $this->dbStatement($stmt);
|
||
|
||
$stmt->setMap($map);
|
||
$stmt->bindValueList($params);
|
||
|
||
return $stmt;
|
||
}
|
||
|
||
/**
|
||
* Метод расширяет PDO::query()
|
||
*/
|
||
public function query(string $query, mixed ...$args): DBStatement|false
|
||
{
|
||
if (
|
||
isset($args[0])
|
||
&& \is_array($args[0])
|
||
) {
|
||
$params = \array_shift($args);
|
||
} else {
|
||
$params = [];
|
||
}
|
||
|
||
$map = $this->parse($query, $params);
|
||
|
||
if (empty($params)) {
|
||
$start = \microtime(true);
|
||
$stmt = $this->pdo->query($query, ...$args);
|
||
|
||
$this->saveQuery($query, \microtime(true) - $start);
|
||
|
||
if (! $stmt instanceof PDOStatement) {
|
||
return false;
|
||
}
|
||
|
||
return $this->dbStatement($stmt);
|
||
}
|
||
|
||
$start = \microtime(true);
|
||
$stmt = $this->pdo->prepare($query);
|
||
$this->delta = \microtime(true) - $start;
|
||
|
||
if (! $stmt instanceof PDOStatement) {
|
||
return false;
|
||
}
|
||
|
||
$stmt = $this->dbStatement($stmt);
|
||
|
||
$stmt->setMap($map);
|
||
|
||
if ($stmt->execute($params)) {
|
||
if (! empty($args)) {
|
||
$stmt->setFetchMode(...$args);
|
||
}
|
||
|
||
return $stmt;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Инициализирует транзакцию
|
||
*/
|
||
public function beginTransaction(): bool
|
||
{
|
||
$start = \microtime(true);
|
||
$result = $this->pdo->beginTransaction();
|
||
|
||
$this->saveQuery('beginTransaction()', \microtime(true) - $start, false);
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Фиксирует транзакцию
|
||
*/
|
||
public function commit(): bool
|
||
{
|
||
$start = \microtime(true);
|
||
$result = $this->pdo->commit();
|
||
|
||
$this->saveQuery('commit()', \microtime(true) - $start, false);
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Откатывает транзакцию
|
||
*/
|
||
public function rollback(): bool
|
||
{
|
||
$start = \microtime(true);
|
||
$result = $this->pdo->rollback();
|
||
|
||
$this->saveQuery('rollback()', \microtime(true) - $start, false);
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Передает вызовы метода в PDO или драйвер текущей базы
|
||
*/
|
||
public function __call(string $name, array $args): mixed
|
||
{
|
||
if (isset($this->pdoMethods[$name])) {
|
||
return $this->pdo->$name(...$args);
|
||
} elseif (empty($this->dbDrv)) {
|
||
$this->dbDrv = new $this->dbDrvClass($this, $this->dbPrefix);
|
||
|
||
// ????? проверка типа
|
||
}
|
||
|
||
return $this->dbDrv->$name(...$args);
|
||
}
|
||
}
|