218 lines
6.6 KiB
PHP
218 lines
6.6 KiB
PHP
<?php
|
||
/**
|
||
* @package Bicycle
|
||
* @author Egor Isaev
|
||
* @description PdoDriver.php
|
||
* @copyright (c) 07/08/2026
|
||
*/
|
||
|
||
namespace Services\DataBase\Classes;
|
||
|
||
use PDO;
|
||
use PDOException;
|
||
use PDOStatement;
|
||
use System\Classes\MyException;
|
||
|
||
/**
|
||
* Обёртка над PDO: ленивое подключение (с перебором хостов при отказе — failover),
|
||
* запросы через Statement (см. Statement::showQuery()) и вложенные транзакции через SAVEPOINT.
|
||
*/
|
||
class PdoDriver
|
||
{
|
||
/** @var PDO|null Подключение; null до первого обращения (connect()/pdo()) */
|
||
protected ?PDO $_pdo = null;
|
||
|
||
/** @var int Текущий уровень вложенности транзакции (0 — вне транзакции) */
|
||
protected int $_transaction_level = 0;
|
||
|
||
/**
|
||
* @param array $config Параметры подключения: host|hosts, dbname, user, password, charset (по умолчанию utf8mb4)
|
||
*/
|
||
public function __construct(protected array $config)
|
||
{
|
||
}
|
||
|
||
/**
|
||
* Подключается к первому доступному хосту из host|hosts (failover).
|
||
* Не делает ничего, если подключение уже установлено.
|
||
*
|
||
* @return void
|
||
* @throws MyException Если не удалось подключиться ни к одному хосту
|
||
*/
|
||
public function connect(): void
|
||
{
|
||
if ($this->_pdo !== null) {
|
||
return;
|
||
}
|
||
|
||
$hosts = $this->config['hosts'] ?? [$this->config['host'] ?? 'localhost'];
|
||
$dbname = $this->config['dbname'] ?? '';
|
||
$user = $this->config['user'] ?? '';
|
||
$password = $this->config['password'] ?? '';
|
||
$charset = $this->config['charset'] ?? 'utf8mb4';
|
||
|
||
$errors = [];
|
||
|
||
foreach ($hosts as $host) {
|
||
$dsn = "mysql:host=$host;dbname=$dbname;charset=$charset";
|
||
|
||
try {
|
||
$pdo = new PDO($dsn, $user, $password, [
|
||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||
]);
|
||
$pdo->setAttribute(PDO::ATTR_STATEMENT_CLASS, [Statement::class, [$this]]);
|
||
|
||
$this->_pdo = $pdo;
|
||
|
||
return;
|
||
} catch (PDOException $e) {
|
||
$errors[] = "$host: {$e->getMessage()}";
|
||
}
|
||
}
|
||
|
||
throw new MyException(
|
||
'Не удалось подключиться ни к одному хосту БД: :errors', [':errors' => implode('; ', $errors)]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @return PDO Подключается при первом обращении
|
||
* @throws MyException
|
||
*/
|
||
public function pdo(): PDO
|
||
{
|
||
$this->connect();
|
||
|
||
return $this->_pdo;
|
||
}
|
||
|
||
/**
|
||
* Готовит выражение (через Statement — см. PDO::ATTR_STATEMENT_CLASS).
|
||
*
|
||
* @param string $sql SQL-запрос
|
||
* @return false|PDOStatement
|
||
* @throws MyException
|
||
*/
|
||
public function prepare(string $sql): false|PDOStatement
|
||
{
|
||
return $this->pdo()->prepare($sql);
|
||
}
|
||
|
||
/**
|
||
* Готовит, выполняет (с профилированием времени — см. ProfilerPDO) и возвращает выражение.
|
||
*
|
||
* @param string $sql SQL-запрос
|
||
* @param array $params Параметры для execute()
|
||
* @return false|PDOStatement
|
||
* @throws MyException
|
||
*/
|
||
public function query(string $sql, array $params = []): false|PDOStatement
|
||
{
|
||
$statement = $this->prepare($sql);
|
||
|
||
ProfilerPDO::wrap($sql, $params, static fn () => $statement->execute($params));
|
||
|
||
return $statement;
|
||
}
|
||
|
||
/**
|
||
* @param string $sql SQL-запрос
|
||
* @return int Число задетых строк
|
||
* @throws MyException
|
||
*/
|
||
public function exec(string $sql): int
|
||
{
|
||
return $this->pdo()->exec($sql);
|
||
}
|
||
|
||
/**
|
||
* Начинает транзакцию; на вложенном уровне — SAVEPOINT.
|
||
*
|
||
* @return bool
|
||
* @throws MyException
|
||
*/
|
||
public function beginTransaction(): bool
|
||
{
|
||
$result = $this->_transaction_level === 0
|
||
? $this->pdo()->beginTransaction()
|
||
: (bool) $this->pdo()->exec('SAVEPOINT sp_' . $this->_transaction_level);
|
||
|
||
$this->_transaction_level++;
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Фиксирует транзакцию; на вложенном уровне — RELEASE SAVEPOINT.
|
||
*
|
||
* @return bool
|
||
* @throws MyException
|
||
*/
|
||
public function commit(): bool
|
||
{
|
||
$this->_transaction_level--;
|
||
|
||
return $this->_transaction_level === 0
|
||
? $this->pdo()->commit()
|
||
: (bool) $this->pdo()->exec('RELEASE SAVEPOINT sp_' . $this->_transaction_level);
|
||
}
|
||
|
||
/**
|
||
* Откатывает транзакцию; на вложенном уровне — ROLLBACK TO SAVEPOINT.
|
||
*
|
||
* @return bool
|
||
* @throws MyException
|
||
*/
|
||
public function rollback(): bool
|
||
{
|
||
$this->_transaction_level--;
|
||
|
||
return $this->_transaction_level === 0
|
||
? $this->pdo()->rollBack()
|
||
: (bool) $this->pdo()->exec('ROLLBACK TO SAVEPOINT sp_' . $this->_transaction_level);
|
||
}
|
||
|
||
/**
|
||
* @return bool Идёт ли сейчас транзакция (на любом уровне вложенности)
|
||
*/
|
||
public function inTransaction(): bool
|
||
{
|
||
return $this->_transaction_level > 0;
|
||
}
|
||
|
||
/**
|
||
* Выполняет $callback в транзакции: begin → callback → commit;
|
||
* при исключении — rollback и повторный throw. Вложенные вызовы (в том числе
|
||
* из разных Repository на одном PdoDriver) используют SAVEPOINT прозрачно.
|
||
*
|
||
* @param callable $callback
|
||
* @return mixed Результат $callback
|
||
* @throws MyException
|
||
*/
|
||
public function transaction(callable $callback): mixed
|
||
{
|
||
$this->beginTransaction();
|
||
|
||
try {
|
||
$result = $callback($this);
|
||
$this->commit();
|
||
|
||
return $result;
|
||
} catch (\Throwable $e) {
|
||
$this->rollback();
|
||
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @return string|false ID последней вставленной строки
|
||
* @throws MyException
|
||
*/
|
||
public function lastInsertId(): string|false
|
||
{
|
||
return $this->pdo()->lastInsertId();
|
||
}
|
||
}
|