Bicycle/System/Classes/Repository.php
Egor Isaev 9d4edf147a dev
2026-08-07 11:40:03 +03:00

326 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 — по умолчанию
* @throws MyException
*/
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
* @throws MyException
*/
public function beginTransaction(): bool
{
return $this->pdo->beginTransaction();
}
/**
* Фиксирует транзакцию; на вложенном уровне — RELEASE SAVEPOINT.
*
* @return bool
* @throws MyException
*/
public function commit(): bool
{
return $this->pdo->commit();
}
/**
* Откатывает транзакцию; на вложенном уровне — ROLLBACK TO SAVEPOINT.
*
* @return bool
* @throws MyException
*/
public function rollBack(): bool
{
return $this->pdo->rollback();
}
/**
* Выполняет $callback в транзакции: begin → callback → commit;
* при исключении — rollBack и повторный throw.
*
* @param callable $callback
* @return mixed
* @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;
}
}
/**
* Получить 1 запись по первичному ключу.
*
* @param int|string $id
* @return T|false
* @throws MyException
*/
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 пуст
* @throws MyException
*/
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_*
* @return bool|T[]
* @throws MyException
*/
public function getList(
string $cols, array $where = [], string $order = '', string $sort = 'ASC', int $fetch = PDO::FETCH_KEY_PAIR
): 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 $stmt->fetchAll($fetch);
}
/**
* Создать запись.
*
* @param array<string,mixed> $data
* @return int lastInsertId
* @throws MyException
*/
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 Число задетых строк
* @throws MyException
*/
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
* @throws MyException
*/
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
* @throws MyException
*/
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;
}
}