Bicycle/System/Classes/Repository.php
Egor Isaev 8fdcd0b901 dev
2026-08-07 13:44:45 +03:00

315 lines
11 KiB
PHP
Raw 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 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;
}
}