Bicycle/Services/DataBase/Classes/PdoConnection.php
Egor Isaev 69ce89a695 dev
2026-08-11 17:05:39 +03:00

256 lines
8.5 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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();
}
}