315 lines
11 KiB
PHP
315 lines
11 KiB
PHP
<?php
|
||
/**
|
||
* @package Bicycle
|
||
* @author Egor Isaev
|
||
* @description Repository.php
|
||
* @copyright (c) 07/08/2026
|
||
*/
|
||
|
||
namespace System\Classes;
|
||
|
||
use JsonException;
|
||
use PDO;
|
||
use Services\Database;
|
||
use Services\DataBase\Classes\PdoDriver;
|
||
use Services\DataBase\Model;
|
||
use Throwable;
|
||
|
||
/**
|
||
* @template T of object
|
||
*
|
||
* Конкретный репозиторий — в App\Repositories\* (например App\Repositories\BudgetRepository
|
||
* extends Repository), таблица/класс строки задаются через конструктор:
|
||
* `parent::__construct('budget', Budget::class)` (по умолчанию — Services\DataBase\Model).
|
||
* Подключение — $this->pdo (PdoDriver, не сырой \PDO — но с тем же query()/prepare()/...
|
||
* API, включая fetch-методы через Statement).
|
||
*/
|
||
abstract class Repository
|
||
{
|
||
/** @var string Имя первичного ключа */
|
||
public string $primary_col = 'id';
|
||
|
||
/** @var bool Первичный ключ — автоинкремент (влияет на processData()/create()) */
|
||
public bool $is_auto_increment = true;
|
||
|
||
/** @var PdoDriver Подключение */
|
||
protected PdoDriver $pdo;
|
||
|
||
/** @var string Таблица, с которой работает репозиторий */
|
||
protected string $table;
|
||
|
||
/** @var class-string<T> Класс, который должен возвращаться из get()/getItemWhere() */
|
||
private string $obj_class;
|
||
|
||
/**
|
||
* @param string $table Таблица
|
||
* @param class-string<T> $obj_class Класс строки (fetchObject()); по умолчанию — лёгкий Model
|
||
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
|
||
*/
|
||
public function __construct(string $table, string $obj_class = Model::class, ?string $connection = null)
|
||
{
|
||
$this->pdo = Database::instance($connection);
|
||
$this->table = $table;
|
||
$this->obj_class = $obj_class;
|
||
}
|
||
|
||
/**
|
||
* Начинает транзакцию; на вложенном уровне — SAVEPOINT (см. PdoDriver — он же считает
|
||
* вложенность: у него общий счётчик на всё подключение, а не свой на каждый Repository,
|
||
* поэтому несколько репозиториев на одном Database::instance() безопасно участвуют
|
||
* в одной внешней транзакции).
|
||
*
|
||
* @return bool
|
||
*/
|
||
public function beginTransaction(): bool
|
||
{
|
||
return $this->pdo->beginTransaction();
|
||
}
|
||
|
||
/**
|
||
* Фиксирует транзакцию; на вложенном уровне — RELEASE SAVEPOINT.
|
||
*
|
||
* @return bool
|
||
*/
|
||
public function commit(): bool
|
||
{
|
||
return $this->pdo->commit();
|
||
}
|
||
|
||
/**
|
||
* Откатывает транзакцию; на вложенном уровне — ROLLBACK TO SAVEPOINT.
|
||
*
|
||
* @return bool
|
||
*/
|
||
public function rollBack(): bool
|
||
{
|
||
return $this->pdo->rollback();
|
||
}
|
||
|
||
/**
|
||
* Выполняет $callback в транзакции: begin → callback → commit;
|
||
* при исключении — rollBack и повторный throw.
|
||
*
|
||
* @param callable $callback
|
||
* @return mixed
|
||
* @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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Получить 1 запись по первичному ключу.
|
||
*
|
||
* @param int|string $id
|
||
* @return T|false
|
||
*/
|
||
public function get(int|string $id): mixed
|
||
{
|
||
$sql = "SELECT * FROM $this->table WHERE $this->primary_col = :id";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->bindValue(':id', $id, is_int($id) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||
$stmt->execute();
|
||
|
||
return $stmt->fetchObject($this->obj_class);
|
||
}
|
||
|
||
/**
|
||
* Получить 1 запись по произвольному условию (без плейсхолдеров — $where подставляется в SQL как есть).
|
||
*
|
||
* @param string $where SQL-условие после WHERE
|
||
* @return T|false|null null, если $where пуст
|
||
*/
|
||
public function getItemWhere(string $where): mixed
|
||
{
|
||
if ($where === '') {
|
||
return null;
|
||
}
|
||
|
||
$sql = "SELECT * FROM $this->table WHERE $where";
|
||
|
||
return $this->pdo->query($sql)->fetchObject($this->obj_class);
|
||
}
|
||
|
||
/**
|
||
* Получить список.
|
||
*
|
||
* @param string $cols 'id, col_1, col_2, ...'
|
||
* @param array $where ['table_col' => 'value', ...] (объединяются через AND; значение 'IS NULL'/'IS NOT NULL' — без плейсхолдера)
|
||
* @param string $order Колонка сортировки
|
||
* @param string $sort ASC|DESC
|
||
* @param int $fetch Режим PDO::FETCH_*; для FETCH_CLASS (по умолчанию) строки — $this->obj_class
|
||
* @return bool|T[]
|
||
*/
|
||
public function getList(
|
||
string $cols, array $where = [], string $order = '', string $sort = 'ASC', int $fetch = PDO::FETCH_CLASS
|
||
): bool|array {
|
||
$str = '';
|
||
|
||
if (!empty($where)) {
|
||
$conditions = [];
|
||
|
||
foreach ($where as $key => $value) {
|
||
if (is_string($value) && (strtoupper($value) === 'IS NOT NULL' || strtoupper($value) === 'IS NULL')) {
|
||
$conditions[] = "$key $value";
|
||
unset($where[$key]);
|
||
} else {
|
||
$conditions[] = "$key = :$key";
|
||
}
|
||
}
|
||
|
||
$str = 'AND ' . implode(' AND ', $conditions);
|
||
}
|
||
|
||
$sql = "SELECT $cols FROM $this->table WHERE 1 $str";
|
||
|
||
if ($order !== '') {
|
||
$sql .= " ORDER BY $order $sort";
|
||
}
|
||
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->execute($where);
|
||
|
||
return $fetch === PDO::FETCH_CLASS ? $stmt->fetchAll($fetch, $this->obj_class) : $stmt->fetchAll($fetch);
|
||
}
|
||
|
||
/**
|
||
* Создать запись.
|
||
*
|
||
* @param array<string,mixed> $data
|
||
* @return int lastInsertId
|
||
*/
|
||
public function create(array $data): int
|
||
{
|
||
$create_data = $this->processData($data);
|
||
|
||
$fields = implode(',', $create_data['fields']);
|
||
$keys = implode(',', $create_data['keys']);
|
||
|
||
$sql = "INSERT INTO $this->table ($fields) VALUES ($keys)";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
|
||
foreach ($create_data['bind'] as $param) {
|
||
$stmt->bindValue(...$param);
|
||
}
|
||
|
||
$stmt->execute();
|
||
|
||
return (int)$this->pdo->lastInsertId();
|
||
}
|
||
|
||
/**
|
||
* Изменить запись по $primary_col.
|
||
*
|
||
* @param array<string,mixed> $data [$primary_col обязателен]
|
||
* @return int Число задетых строк
|
||
*/
|
||
public function update(array $data): int
|
||
{
|
||
if (!isset($data[$this->primary_col])) {
|
||
throw new MyException('Массив не содержит :key', [':key' => $this->primary_col]);
|
||
}
|
||
|
||
$update_data = $this->processData($data);
|
||
|
||
$set = implode(', ', $update_data['set']);
|
||
$sql = "UPDATE $this->table SET $set WHERE $this->primary_col = :id";
|
||
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->bindValue(':id', $data[$this->primary_col], is_int($data[$this->primary_col]) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||
|
||
foreach ($update_data['bind'] as $param) {
|
||
$stmt->bindValue(...$param);
|
||
}
|
||
|
||
$stmt->execute();
|
||
|
||
return $stmt->rowCount();
|
||
}
|
||
|
||
/**
|
||
* Удалить запись по $primary_col.
|
||
*
|
||
* @param int|string $id
|
||
* @return bool
|
||
*/
|
||
public function delete(int|string $id): bool
|
||
{
|
||
$sql = "DELETE FROM $this->table WHERE $this->primary_col = :id";
|
||
$stmt = $this->pdo->prepare($sql);
|
||
$stmt->bindValue(':id', $id, is_int($id) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||
|
||
return $stmt->execute();
|
||
}
|
||
|
||
/**
|
||
* Удалить записи по произвольному условию.
|
||
*
|
||
* @param string $where SQL-условие после WHERE (с плейсхолдерами)
|
||
* @param array<int|string,mixed> $params Параметры для плейсхолдеров
|
||
* @return bool
|
||
*/
|
||
public function deleteWhere(string $where, array $params): bool
|
||
{
|
||
$sql = "DELETE FROM $this->table WHERE $where";
|
||
|
||
return $this->pdo->prepare($sql)->execute($params);
|
||
}
|
||
|
||
/**
|
||
* Обработать данные для create()/update(): собрать fields/keys (INSERT) или set (UPDATE)
|
||
* и bind-параметры с определением типа PDO::PARAM_* по значению. $primary_col пропускается,
|
||
* если is_auto_increment (в create()); в set попадает, если явно присутствует в $data.
|
||
*
|
||
* @param array<string,mixed> $data
|
||
* @return array{fields?:array<int,string>,keys?:array<int,string>,set?:array<int,string>,bind:array<int,array>}
|
||
*/
|
||
protected function processData(array $data): array
|
||
{
|
||
$result = ['bind' => []];
|
||
|
||
foreach ($data as $key => $value) {
|
||
if ($this->is_auto_increment && $key === $this->primary_col) {
|
||
continue;
|
||
}
|
||
|
||
if (array_key_exists($this->primary_col, $data)) {
|
||
$result['set'][] = "`$key` = :$key";
|
||
}
|
||
|
||
$result['fields'][] = "`$key`";
|
||
$result['keys'][] = ":$key";
|
||
|
||
if (is_int($value)) {
|
||
$result['bind'][] = [":$key", $value, PDO::PARAM_INT];
|
||
} elseif (is_string($value) || is_float($value)) {
|
||
$result['bind'][] = [":$key", $value, PDO::PARAM_STR];
|
||
} elseif (is_bool($value)) {
|
||
$result['bind'][] = [":$key", $value, PDO::PARAM_BOOL];
|
||
} elseif ($value === null) {
|
||
$result['bind'][] = [":$key", $value, PDO::PARAM_NULL];
|
||
} elseif (is_array($value)) {
|
||
try {
|
||
$result['bind'][] = [":$key", json_encode($value, JSON_THROW_ON_ERROR), PDO::PARAM_STR];
|
||
} catch (JsonException) {
|
||
$result['bind'][] = [":$key", null, PDO::PARAM_STR];
|
||
}
|
||
}
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
}
|