256 lines
8.5 KiB
PHP
256 lines
8.5 KiB
PHP
<?php
|
||
/**
|
||
* @package Bicycle
|
||
* @author Egor Isaev
|
||
* @description PdoConnection.php
|
||
* @copyright (c) 07/08/2026
|
||
*/
|
||
|
||
namespace Services\DataBase\Classes;
|
||
|
||
use PDO;
|
||
use PDOException;
|
||
use PDOStatement;
|
||
use System\Classes\MyException;
|
||
use Throwable;
|
||
|
||
/**
|
||
* Обёртка над PDO: ленивое подключение (с перебором хостов при отказе — failover),
|
||
* запросы через Statement (см. Statement::showQuery()) и вложенные транзакции через SAVEPOINT.
|
||
* Диалект (`mysql`/`pgsql`/`sqlite` — набор PDO-драйверов, реально доступных через
|
||
* pdo_mysql/pdo_pgsql/pdo_sqlite) задаётся конфигом, не подклассом — DSN и есть единственное
|
||
* отличие между ними на уровне этого класса, остального (Statement/Profiler/SAVEPOINT-транзакции)
|
||
* это не касается.
|
||
*/
|
||
class PdoConnection
|
||
{
|
||
/** @var PDO|null Подключение; null до первого обращения (connect()/pdo()) */
|
||
protected ?PDO $_pdo = null;
|
||
|
||
/** @var int Текущий уровень вложенности транзакции (0 — вне транзакции) */
|
||
protected int $_transaction_level = 0;
|
||
|
||
/**
|
||
* @param array $config Параметры подключения: dialect (mysql|pgsql|sqlite, по умолчанию mysql),
|
||
* host|hosts, port, dbname, user, password, charset (по умолчанию utf8mb4,
|
||
* только для mysql). Для sqlite dbname — путь к файлу (или ':memory:'),
|
||
* host/port/user/password/charset не используются.
|
||
*/
|
||
public function __construct(protected array $config)
|
||
{
|
||
}
|
||
|
||
/**
|
||
* Подключается к первому доступному хосту из host|hosts (failover); для sqlite (нет
|
||
* понятия хоста — файл или ':memory:') подключается напрямую, без перебора.
|
||
* Не делает ничего, если подключение уже установлено.
|
||
*
|
||
* @return void
|
||
*/
|
||
public function connect(): void
|
||
{
|
||
if ($this->_pdo !== null) {
|
||
return;
|
||
}
|
||
|
||
$dialect = $this->config['dialect'] ?? 'mysql';
|
||
$options = [
|
||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||
];
|
||
|
||
if ($dialect === 'sqlite') {
|
||
$this->_pdo = $this->connectPdo($this->buildDsn($dialect, null), $options);
|
||
|
||
return;
|
||
}
|
||
|
||
$hosts = $this->config['hosts'] ?? [$this->config['host'] ?? 'localhost'];
|
||
$errors = [];
|
||
|
||
foreach ($hosts as $host) {
|
||
try {
|
||
$this->_pdo = $this->connectPdo($this->buildDsn($dialect, $host), $options);
|
||
|
||
return;
|
||
} catch (PDOException $e) {
|
||
$errors[] = "$host: {$e->getMessage()}";
|
||
}
|
||
}
|
||
|
||
throw new MyException(
|
||
'Не удалось подключиться ни к одному хосту БД: :errors', [':errors' => implode('; ', $errors)]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Собирает DSN под конкретный PDO-диалект.
|
||
*
|
||
* @param string $dialect mysql|pgsql|sqlite
|
||
* @param string|null $host Хост; не используется для sqlite
|
||
* @return string
|
||
*/
|
||
protected function buildDsn(string $dialect, ?string $host): string
|
||
{
|
||
$dbname = $this->config['dbname'] ?? '';
|
||
$port = $this->config['port'] ?? null;
|
||
$port_suffix = $port !== null ? ";port=$port" : '';
|
||
|
||
return match ($dialect) {
|
||
'sqlite' => "sqlite:$dbname",
|
||
'pgsql' => "pgsql:host=$host;dbname=$dbname$port_suffix",
|
||
default => "mysql:host=$host;dbname=$dbname;charset=" . ($this->config['charset'] ?? 'utf8mb4') . $port_suffix,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Открывает PDO-подключение по готовому DSN и настраивает Statement::class.
|
||
* sqlite не использует user/password — PDO принимает для него null.
|
||
*
|
||
* @param string $dsn
|
||
* @param array $options
|
||
* @return PDO
|
||
*/
|
||
protected function connectPdo(string $dsn, array $options): PDO
|
||
{
|
||
$is_sqlite = str_starts_with($dsn, 'sqlite:');
|
||
$user = $is_sqlite ? null : ($this->config['user'] ?? '');
|
||
$password = $is_sqlite ? null : ($this->config['password'] ?? '');
|
||
|
||
$pdo = new PDO($dsn, $user, $password, $options);
|
||
$pdo->setAttribute(PDO::ATTR_STATEMENT_CLASS, [Statement::class, [$this]]);
|
||
|
||
return $pdo;
|
||
}
|
||
|
||
/**
|
||
* @return PDO Подключается при первом обращении
|
||
*/
|
||
public function pdo(): PDO
|
||
{
|
||
$this->connect();
|
||
|
||
return $this->_pdo;
|
||
}
|
||
|
||
/**
|
||
* Готовит выражение (через Statement — см. PDO::ATTR_STATEMENT_CLASS).
|
||
*
|
||
* @param string $sql SQL-запрос
|
||
* @return false|PDOStatement
|
||
*/
|
||
public function prepare(string $sql): false|PDOStatement
|
||
{
|
||
return $this->pdo()->prepare($sql);
|
||
}
|
||
|
||
/**
|
||
* Готовит и выполняет запрос (профилирование времени — см. Statement::execute()).
|
||
*
|
||
* @param string $sql SQL-запрос
|
||
* @param array $params Параметры для execute()
|
||
* @return false|PDOStatement
|
||
*/
|
||
public function query(string $sql, array $params = []): false|PDOStatement
|
||
{
|
||
$statement = $this->prepare($sql);
|
||
$statement->execute($params);
|
||
|
||
return $statement;
|
||
}
|
||
|
||
/**
|
||
* @param string $sql SQL-запрос
|
||
* @return int Число задетых строк
|
||
*/
|
||
public function exec(string $sql): int
|
||
{
|
||
return $this->pdo()->exec($sql);
|
||
}
|
||
|
||
/**
|
||
* Начинает транзакцию; на вложенном уровне — SAVEPOINT.
|
||
*
|
||
* @return bool
|
||
*/
|
||
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
|
||
*/
|
||
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
|
||
*/
|
||
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 на одном PdoConnection) используют SAVEPOINT прозрачно.
|
||
*
|
||
* @param callable $callback
|
||
* @return mixed Результат $callback
|
||
* @throws Throwable
|
||
*/
|
||
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 последней вставленной строки
|
||
*/
|
||
public function lastInsertId(): string|false
|
||
{
|
||
return $this->pdo()->lastInsertId();
|
||
}
|
||
}
|