dev
This commit is contained in:
parent
af0c247403
commit
9d4edf147a
@ -7,8 +7,9 @@
|
|||||||
- [Не коммитить самому](feedback_no_commit.md) — git commit/push делает пользователь вручную
|
- [Не коммитить самому](feedback_no_commit.md) — git commit/push делает пользователь вручную
|
||||||
- [Общение по-русски](feedback_russian_communication.md) — включая описания Bash-вызовов и подобных tool-call'ов
|
- [Общение по-русски](feedback_russian_communication.md) — включая описания Bash-вызовов и подобных tool-call'ов
|
||||||
- [Меньше уточнений](feedback_less_confirmation.md) — не переспрашивать по некритичным/обратимым шагам
|
- [Меньше уточнений](feedback_less_confirmation.md) — не переспрашивать по некритичным/обратимым шагам
|
||||||
|
- [Не делать лишнего](feedback_no_unprompted_scope.md) — не выносить абстракции "про запас", не додумывать причины, вопрос ≠ команда действовать
|
||||||
- [Auth API — терминология Kohana](feedback_auth_api_naming.md) — login/logout/loggedIn/getUser/checkPassword, Auth::instance(), без слова Provider
|
- [Auth API — терминология Kohana](feedback_auth_api_naming.md) — login/logout/loggedIn/getUser/checkPassword, Auth::instance(), без слова Provider
|
||||||
- [Учебный проект](feedback_educational_project.md) — не предлагать замену на Laravel/Symfony
|
- [Учебный проект](feedback_educational_project.md) — не предлагать замену на Laravel/Symfony
|
||||||
- [Место хранения памяти](feedback_memory_location.md) — вся память по Bicycle тут, не в глобальном auto-memory
|
- [Место хранения памяти](feedback_memory_location.md) — вся память по Bicycle тут, не в глобальном auto-memory
|
||||||
- [Roadmap: слой БД](roadmap.md) — Model, Database/PdoDriver, Statement, Repository, Profiler
|
- [Roadmap: слой БД](roadmap.md) — MariaDB/PDO реализован и протестирован; Mongo/Elasticsearch написаны, ждут внешней инфры от пользователя
|
||||||
- [Идея: CLI-точка входа](idea_cli_entrypoint.md) — отложенный cli.php для миграций/крона
|
- [Идея: CLI-точка входа](idea_cli_entrypoint.md) — отложенный cli.php для миграций/крона
|
||||||
|
|||||||
26
.claude/memory/feedback_no_unprompted_scope.md
Normal file
26
.claude/memory/feedback_no_unprompted_scope.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: feedback-no-unprompted-scope
|
||||||
|
description: Не делать лишних действий сверх того, что попросили — не выносить абстракции "на будущее", не додумывать причины
|
||||||
|
metadata:
|
||||||
|
type: feedback
|
||||||
|
---
|
||||||
|
|
||||||
|
Пользователь прямо указал: «ты делаешь много лишних действий которые тебя не просят». Конкретный кейс —
|
||||||
|
при рефакторинге `Controller::render()`/`renderContent()` (см. [[project-bicycle-overview]]) я вынес
|
||||||
|
расчёт каталога view в отдельный приватный метод `contentDir()` без реальной причины (вызывался из
|
||||||
|
одного места) — просто рефлекс «похожая логика — надо вынести», а не ответ на реальную потребность.
|
||||||
|
Пользователь спросил «мы где-то ещё будем использовать contentDir()?» не как команду переделать,
|
||||||
|
а как проверочный вопрос — заподозрил лишнюю абстракцию и хотел понять мою логику.
|
||||||
|
|
||||||
|
**Why:** Лишние абстракции/методы «про запас» усложняют код без пользы (см. также правило проекта —
|
||||||
|
не проектировать под гипотетические будущие требования) и создают у пользователя ощущение, что я
|
||||||
|
действую самостоятельно там, где не просили.
|
||||||
|
|
||||||
|
**How to apply:** Не выносить код в отдельный метод/абстракцию, если на это нет явного запроса или
|
||||||
|
явного текущего переиспользования (2+ реальных вызова прямо сейчас). Если сомневаюсь, нужна ли
|
||||||
|
абстракция — оставлять код инлайн, как было. Это не противоречит [[feedback-less-confirmation]]:
|
||||||
|
там речь про паузы-подтверждения на уже согласованных, обратимых шагах; здесь — про то, чтобы вообще
|
||||||
|
не расширять объём работы (лишние методы, лишний рефакторинг, лишние файлы) сверх того, что запросили.
|
||||||
|
Если вопрос пользователя похож на «а мы это ещё где-то используем/будем использовать?» — это, как
|
||||||
|
правило, не команда действовать, а проверка моей логики; сначала ответить прямо, не хвататься за
|
||||||
|
редактирование кода.
|
||||||
@ -1,20 +1,47 @@
|
|||||||
---
|
---
|
||||||
name: roadmap
|
name: roadmap
|
||||||
description: Что ещё не реализовано в Bicycle — слой БД и связанные классы
|
description: Статус слоя БД (MariaDB/Mongo/Elasticsearch) в Bicycle — что реализовано, что ждёт внешней инфраструктуры
|
||||||
metadata:
|
metadata:
|
||||||
type: project
|
type: project
|
||||||
---
|
---
|
||||||
|
|
||||||
Запланировано, но пока не реализовано (по состоянию на 2026-08-06):
|
Реализовано (2026-08-07) — три независимых сервиса, каждый со своей фабрикой в духе `Services\Auth`,
|
||||||
|
без общего интерфейса поверх всех трёх (SQL/документы/поиск — слишком разные модели, чтобы прятать
|
||||||
|
за одной абстракцией). Подробности использования — CLAUDE.md → разделы DataBase/Elasticsearch/Mongo.
|
||||||
|
|
||||||
- `Model` — лёгкий base-класс с `__get`/`__set`, `toArray()`.
|
- **Реляционная БД (MariaDB, полностью рабочая и протестированная)** — `Services\Database` (фабрика),
|
||||||
- `Database` + `PdoDriver` — PDO singleton с failover.
|
`Services\DataBase\Classes\PdoDriver` (failover по хостам, транзакции с вложенными SAVEPOINT),
|
||||||
- `Statement` — расширение `PDOStatement` (`showQuery()`/`sq()`).
|
`Services\DataBase\Classes\Statement` (`showQuery()`/`sq()`), `Services\DataBase\Classes\Profiler`/`ProfilerPDO`
|
||||||
- `Repository` — abstract CRUD с транзакциями/SAVEPOINT.
|
(тайминги), `Services\DataBase\Model` (лёгкий носитель атрибутов). `System\Classes\Repository` — abstract
|
||||||
- `Profiler` + `ProfilerPDO` — тайминги SQL-запросов.
|
CRUD; конкретные репозитории — в `App/Repositories/*` (например `App\Repositories\BudgetRepository`),
|
||||||
|
не в `Services\DataBase` — так решил пользователь явно.
|
||||||
|
- **Elasticsearch** — `Services\Elasticsearch` + `Services\Elasticsearch\Client`, тонкая REST-обёртка на
|
||||||
|
уже существующем `System\Classes\HTTP\Client\Curl` (без тяжёлой composer-зависимости
|
||||||
|
`elasticsearch/elasticsearch` — см. `feedback_educational_project.md`).
|
||||||
|
- **Mongo** — `Services\Mongo` + `Services\Mongo\MongoDriver`, обёртка над `MongoDB\Client`
|
||||||
|
(композер-пакет `mongodb/mongodb` уже поставлен через `composer.phar require --ignore-platform-req=ext-mongodb`,
|
||||||
|
плюс `"platform": {"ext-mongodb": "2.3"}` в `composer.json`, чтобы дальнейшие `composer install` не
|
||||||
|
требовали флага).
|
||||||
|
|
||||||
Также пустые каталоги-заготовки без кода: `Services/DataBase`, `Services/Mail`, `Services/PDF`, `App/Classes/` (см. CLAUDE.md → Directory layout).
|
**Что ждёт внешней инфраструктуры (не моя зона — пользователь занимается сам):**
|
||||||
|
|
||||||
**Why:** Это следующий крупный слой фреймворка (доступ к БД) — под него уже есть заготовка в composer.json/каталогах, но кода ещё нет.
|
- `ext-mongodb` не установлен в `docker/php-apache/8.2.8/Dockerfile` (общий для нескольких проектов
|
||||||
|
на хосте — мне трогать нельзя). Пользователь добавит сам. Код написан безопасно без расширения —
|
||||||
|
падает только на реальном `new MongoDB\Client()` внутри `MongoDriver::__construct()`, не раньше.
|
||||||
|
- Контейнеров `mongo` и `elasticsearch` в `docker-dev/docker-compose.yml` нет — пользователь поднимет сам
|
||||||
|
(there уже есть закомментированный черновик блока `elasticsearch` в файле).
|
||||||
|
- БД `bicycle` на общем MariaDB (контейнер `db`) ещё не создана, `App/config/config.local.php` пустой
|
||||||
|
(0 байт) — реальный пароль/пользователь для MariaDB, а также для Mongo/ES (если с авторизацией) —
|
||||||
|
пользователь впишет сам.
|
||||||
|
|
||||||
**How to apply:** При запросах, касающихся БД/моделей/репозиториев — предлагать реализацию в этом стиле (не ORM-фреймворк вроде Doctrine/Eloquent, а лёгкая обёртка в духе остального проекта). См. также `idea_cli_entrypoint.md` — CLI-точка входа отложена именно до появления этого слоя (первый реальный потребитель — миграции).
|
**Тесты**: `PdoDriverTest`/`StatementTest`/`RepositoryTest` бьют по реальной MariaDB (не мокают) — пока
|
||||||
|
БД не настроена, гейтятся через `markTestSkipped()` в `setUp()` при неудачном подключении.
|
||||||
|
`ElasticsearchClientTest` — то же самое через `Client::ping()`. `MongoDriverTest` — через
|
||||||
|
`extension_loaded('mongodb')`. Все они оживут сами, без правки кода, как только пользователь настроит
|
||||||
|
соответствующую инфраструктуру — сейчас (2026-08-07) 26 тестов из 229 пропущены именно поэтому.
|
||||||
|
|
||||||
|
**How to apply:** При запросах, касающихся БД/моделей/репозиториев — этот слой уже есть, не писать
|
||||||
|
заново с нуля, расширять существующие классы. Новый конкретный репозиторий — в `App/Repositories/*
|
||||||
|
extends System\Classes\Repository`, не в `Services\DataBase`. См. также `idea_cli_entrypoint.md` —
|
||||||
|
CLI-точка входа отложена именно до появления этого слоя (первый реальный потребитель — миграции;
|
||||||
|
теперь, когда слой БД есть, эта идея стала актуальнее).
|
||||||
|
|||||||
26
App/Controller/AboutController.php
Normal file
26
App/Controller/AboutController.php
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description AboutController.php
|
||||||
|
* @copyright (c) 07/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use System\Classes\Controller;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Страница «О нас». URL: /about
|
||||||
|
*/
|
||||||
|
class AboutController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
* @throws \System\Classes\MyException
|
||||||
|
*/
|
||||||
|
public function indexAction(): string
|
||||||
|
{
|
||||||
|
return $this->render('about');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,6 +13,8 @@ use System\Classes\Controller;
|
|||||||
/**
|
/**
|
||||||
* Базовый контроллер админки. Рендерится в общем layout сайта
|
* Базовый контроллер админки. Рендерится в общем layout сайта
|
||||||
* (App/view/layout.html), своего отдельного шаблона не имеет.
|
* (App/view/layout.html), своего отдельного шаблона не имеет.
|
||||||
|
* Боковое меню — общее для всего сайта, см. Controller::menuSide()
|
||||||
|
* (для роли admin оно показывается на любой странице, не только в админке).
|
||||||
* Требует авторизацию с ролью admin (см. Controller::$_auth_protection/$_auth_roles) —
|
* Требует авторизацию с ролью admin (см. Controller::$_auth_protection/$_auth_roles) —
|
||||||
* неавторизованный редиректится на /login, авторизованный без роли admin получает 403.
|
* неавторизованный редиректится на /login, авторизованный без роли admin получает 403.
|
||||||
* Конкретные страницы (Logs и т.п.) наследуют его.
|
* Конкретные страницы (Logs и т.п.) наследуют его.
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Repositories\BudgetRepository;
|
||||||
use System\Classes\Controller;
|
use System\Classes\Controller;
|
||||||
use System\Classes\HTTP\Client\Curl;
|
use System\Classes\HTTP\Client\Curl;
|
||||||
use System\Classes\HTTP\Client\Request as HttpRequest;
|
use System\Classes\HTTP\Client\Request as HttpRequest;
|
||||||
@ -25,7 +26,9 @@ class IndexController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function indexAction(): string
|
public function indexAction(): string
|
||||||
{
|
{
|
||||||
$todo = [];
|
$budgetR = new BudgetRepository();
|
||||||
|
|
||||||
|
var_dump($budgetR->getLists());die;
|
||||||
|
|
||||||
return $this->render('index', ['todo' => $todo]);
|
return $this->render('index', ['todo' => $todo]);
|
||||||
}
|
}
|
||||||
|
|||||||
30
App/Controller/NewsController.php
Normal file
30
App/Controller/NewsController.php
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description NewsController.php
|
||||||
|
* @copyright (c) 07/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use System\Classes\Controller;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Страница «Новости». URL: /news
|
||||||
|
*/
|
||||||
|
class NewsController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
* @throws \System\Classes\MyException
|
||||||
|
*/
|
||||||
|
public function indexAction(): string
|
||||||
|
{
|
||||||
|
$news = [
|
||||||
|
['date' => '2026-08-07', 'title' => 'Запущен Bicycle', 'text' => 'Первый релиз самописного PHP MVC-фреймворка.'],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->render('news', ['news' => $news]);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
App/Repositories/BudgetRepository.php
Normal file
38
App/Repositories/BudgetRepository.php
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description BudgetRepository.php
|
||||||
|
* @copyright (c) 07/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Repositories;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use System\Classes\MyException;
|
||||||
|
use System\Classes\Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пример конкретного репозитория поверх System\Classes\Repository — CRUD для таблицы budget.
|
||||||
|
*/
|
||||||
|
class BudgetRepository extends Repository
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct('budget');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
* @throws MyException
|
||||||
|
*/
|
||||||
|
public function getLists(): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT * FROM budget";
|
||||||
|
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
return $stmt->fetchAll(PDO::FETCH_CLASS);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,14 +10,30 @@ return [
|
|||||||
'default' => [
|
'default' => [
|
||||||
'driver' => 'pdo',
|
'driver' => 'pdo',
|
||||||
'connection' => [
|
'connection' => [
|
||||||
'host' => 'db',
|
'host' => '192.168.1.104',
|
||||||
'dbname' => 'bicycle',
|
|
||||||
'user' => 'root',
|
'user' => 'root',
|
||||||
'password' => '',
|
'password' => 'password',
|
||||||
|
'dbname' => 'MatMed_BudgetV2'
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'elasticsearch' => [
|
||||||
|
'default' => [
|
||||||
|
'base_url' => 'http://elasticsearch:9200',
|
||||||
|
'timeout' => 10,
|
||||||
|
'username' => null,
|
||||||
|
'password' => null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'mongo' => [
|
||||||
|
'default' => [
|
||||||
|
'uri' => 'mongodb://mongo:27017',
|
||||||
|
'database' => 'bicycle',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
'session' => [
|
'session' => [
|
||||||
'cookie_params' => [
|
'cookie_params' => [
|
||||||
'lifetime' => 0,
|
'lifetime' => 0,
|
||||||
|
|||||||
1
App/media/css/app.css
Normal file
1
App/media/css/app.css
Normal file
@ -0,0 +1 @@
|
|||||||
|
/* Bicycle — собственные стили сайта поверх Bootstrap */
|
||||||
1
App/media/js/app.js
Normal file
1
App/media/js/app.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
// Bicycle — собственный JS сайта
|
||||||
6
App/media/manifest.json
Normal file
6
App/media/manifest.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "Bicycle",
|
||||||
|
"short_name": "Bicycle",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
13
App/view/About/about.html
Normal file
13
App/view/About/about.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @description about.html
|
||||||
|
* @copyright (c) 07/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
$this->setStyle('app.css');
|
||||||
|
$this->setScript('app.js');
|
||||||
|
?>
|
||||||
|
|
||||||
|
<h1 class="h3 mb-3">О нас</h1>
|
||||||
|
<p>Bicycle — самописный PHP MVC micro-framework. Название отражает идиому «изобрести велосипед».</p>
|
||||||
@ -8,4 +8,6 @@
|
|||||||
|
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<?php var_dump($todo); ?>
|
<h1 class="h3 mb-3">Главная</h1>
|
||||||
|
<p>Добро пожаловать в Bicycle — самописный PHP MVC micro-framework.</p>
|
||||||
|
|
||||||
|
|||||||
18
App/view/News/news.html
Normal file
18
App/view/News/news.html
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @description news.html
|
||||||
|
*
|
||||||
|
* @var array $news [['date','title','text'], ...]
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
|
||||||
|
<h1 class="h3 mb-3">Новости</h1>
|
||||||
|
|
||||||
|
<?php foreach ($news as $item): ?>
|
||||||
|
<article class="mb-3">
|
||||||
|
<div class="text-muted small"><?= htmlspecialchars($item['date'], ENT_QUOTES, 'UTF-8') ?></div>
|
||||||
|
<h2 class="h5 mb-1"><?= htmlspecialchars($item['title'], ENT_QUOTES, 'UTF-8') ?></h2>
|
||||||
|
<p class="mb-0"><?= htmlspecialchars($item['text'], ENT_QUOTES, 'UTF-8') ?></p>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
@ -1,10 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* @package Bicycle
|
* @package Bicycle
|
||||||
* @description layout.html — главный шаблон сайта с верхним меню
|
* @description layout.html — главный шаблон сайта; верхнее и боковое меню
|
||||||
|
* приходят уже отрендеренными (см. Controller::renderMenuTop/renderMenuSide)
|
||||||
*
|
*
|
||||||
* @var string $content Содержимое страницы
|
* @var string $content Содержимое страницы
|
||||||
* @var array $menu Пункты меню [['title','url','active'], ...]
|
* @var string $menu_top HTML верхнего меню
|
||||||
|
* @var string $menu_side HTML бокового меню; пустая строка — колонка не рисуется
|
||||||
*/
|
*/
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@ -15,27 +17,25 @@
|
|||||||
<title>Bicycle</title>
|
<title>Bicycle</title>
|
||||||
<link rel="stylesheet" href="/vendor/twbs/bootstrap/dist/css/bootstrap.min.css">
|
<link rel="stylesheet" href="/vendor/twbs/bootstrap/dist/css/bootstrap.min.css">
|
||||||
<link rel="stylesheet" href="/vendor/twbs/bootstrap-icons/font/bootstrap-icons.css">
|
<link rel="stylesheet" href="/vendor/twbs/bootstrap-icons/font/bootstrap-icons.css">
|
||||||
|
<?php $this->getStyles(); ?>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar navbar-expand navbar-light bg-light border-bottom">
|
<?= $menu_top ?>
|
||||||
<div class="container">
|
<?php if ($menu_side !== ''): ?>
|
||||||
<a class="navbar-brand" href="/">Bicycle</a>
|
<div class="container py-3">
|
||||||
<ul class="navbar-nav">
|
<div class="row">
|
||||||
<?php foreach (($menu ?? []) as $item): ?>
|
<aside class="col-md-3 col-lg-2 mb-3"><?= $menu_side ?></aside>
|
||||||
<li class="nav-item">
|
<main class="col-md-9 col-lg-10"><?= $content ?></main>
|
||||||
<a class="nav-link<?= !empty($item['active']) ? ' active fw-bold' : '' ?>"
|
|
||||||
href="<?= htmlspecialchars($item['url'], ENT_QUOTES, 'UTF-8') ?>">
|
|
||||||
<?= htmlspecialchars($item['title'], ENT_QUOTES, 'UTF-8') ?>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</div>
|
||||||
<main class="container py-3">
|
<?php else: ?>
|
||||||
|
<main class="container py-3">
|
||||||
<?= $content ?>
|
<?= $content ?>
|
||||||
</main>
|
</main>
|
||||||
|
<?php endif; ?>
|
||||||
<script src="/vendor/components/jquery/jquery.min.js"></script>
|
<script src="/vendor/components/jquery/jquery.min.js"></script>
|
||||||
<script src="/vendor/twbs/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="/vendor/twbs/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<?php $this->getScripts(); ?>
|
||||||
|
<?= \System\Classes\ProfilerToolbar::render() ?>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
16
App/view/menu_side.html
Normal file
16
App/view/menu_side.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @description menu_side.html — боковое меню раздела (Bootstrap list-group)
|
||||||
|
*
|
||||||
|
* @var array $menu Пункты меню [['title','url','active'], ...]
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<div class="list-group">
|
||||||
|
<?php foreach (($menu ?? []) as $item): ?>
|
||||||
|
<a class="list-group-item list-group-item-action<?= !empty($item['active']) ? ' active' : '' ?>"
|
||||||
|
href="<?= htmlspecialchars($item['url'], ENT_QUOTES, 'UTF-8') ?>">
|
||||||
|
<?= htmlspecialchars($item['title'], ENT_QUOTES, 'UTF-8') ?>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
23
App/view/menu_top.html
Normal file
23
App/view/menu_top.html
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @description menu_top.html — верхнее меню сайта (Bootstrap navbar)
|
||||||
|
*
|
||||||
|
* @var array $menu Пункты меню [['title','url','active'], ...]
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<nav class="navbar navbar-expand navbar-light bg-light border-bottom">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="/">Bicycle</a>
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
<?php foreach (($menu ?? []) as $item): ?>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link<?= !empty($item['active']) ? ' active fw-bold' : '' ?>"
|
||||||
|
href="<?= htmlspecialchars($item['url'], ENT_QUOTES, 'UTF-8') ?>">
|
||||||
|
<?= htmlspecialchars($item['title'], ENT_QUOTES, 'UTF-8') ?>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
187
CLAUDE.md
187
CLAUDE.md
@ -38,17 +38,21 @@ System/Classes/HTTP/ — HTTP-инфраструктура: инт
|
|||||||
System/Classes/HTTP/Client/ — исходящие HTTP-запросы (Curl, Request, Response)
|
System/Classes/HTTP/Client/ — исходящие HTTP-запросы (Curl, Request, Response)
|
||||||
System/Classes/HTTP/Exception/ — HTTPException_302/403/404
|
System/Classes/HTTP/Exception/ — HTTPException_302/403/404
|
||||||
App/Controller/ — контроллеры приложения (namespace App\Controller\)
|
App/Controller/ — контроллеры приложения (namespace App\Controller\)
|
||||||
|
App/Repositories/ — конкретные репозитории (extends System\Classes\Repository), например BudgetRepository
|
||||||
App/config/ — конфиги (config.php; config.local.php — в .gitignore)
|
App/config/ — конфиги (config.php; config.local.php — в .gitignore)
|
||||||
App/view/ — шаблоны приложения (.html файлы с PHP-кодом)
|
App/view/ — шаблоны приложения (.html файлы с PHP-кодом)
|
||||||
App/media/ — статические ресурсы (js, css, img)
|
App/media/ — статические ресурсы (js, css, img)
|
||||||
Services/Auth/ — авторизация: интерфейс AuthDriver + FileAuthDriver (см. раздел Auth ниже)
|
Services/Auth/ — авторизация: интерфейс AuthDriver + FileAuthDriver (см. раздел Auth ниже)
|
||||||
Services/DataBase/, Mail/, PDF/ — пустые каталоги-заготовки под будущие сервисы (PDO / PHPMailer / dompdf); кода пока нет
|
Services/Database.php, DataBase/ — реляционная БД (MariaDB/PDO): фабрика + Model/Classes/{PdoDriver,Statement,Profiler,ProfilerPDO} (см. раздел DataBase ниже)
|
||||||
|
Services/Elasticsearch.php, Elasticsearch/ — поиск: фабрика + Client (REST, см. раздел Elasticsearch ниже)
|
||||||
|
Services/Mongo.php, Mongo/ — документная БД: фабрика + MongoDriver (см. раздел Mongo ниже)
|
||||||
|
Services/Mail/, PDF/ — пустые каталоги-заготовки под будущие сервисы (PHPMailer / dompdf); кода пока нет
|
||||||
System/view/ — системные шаблоны (ошибки 404/500/403, exception)
|
System/view/ — системные шаблоны (ошибки 404/500/403, exception)
|
||||||
tools/sort_html_attrs.php — CLI-утилита сортировки HTML-атрибутов (gitignored: /tools/* в .gitignore)
|
tools/sort_html_attrs.php — CLI-утилита сортировки HTML-атрибутов (gitignored: /tools/* в .gitignore)
|
||||||
tests/ — PHPUnit тесты
|
tests/ — PHPUnit тесты
|
||||||
```
|
```
|
||||||
|
|
||||||
> Каталог `App/Classes/` и часть `Services/*` (DataBase, Mail, PDF) ещё не созданы — namespace-конвенции ниже описывают, *куда* класть код, когда он появится, а не существующие файлы.
|
> Каталог `App/Classes/` и часть `Services/*` (Mail, PDF) ещё не созданы — namespace-конвенции ниже описывают, *куда* класть код, когда он появится, а не существующие файлы.
|
||||||
|
|
||||||
### Autoload (composer.json)
|
### Autoload (composer.json)
|
||||||
|
|
||||||
@ -76,8 +80,9 @@ PhpStorm может показывать предупреждение «Namespac
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `System\Classes\Core` | Bootstrap, обработка ошибок, поиск файлов (`findFile`), константы среды |
|
| `System\Classes\Core` | Bootstrap, обработка ошибок, поиск файлов (`findFile`), константы среды |
|
||||||
| `System\Classes\BaseController` | Голое ядро (abstract); жизненный цикл `executeAction()`: `before()` → экшен → `after()` |
|
| `System\Classes\BaseController` | Голое ядро (abstract); жизненный цикл `executeAction()`: `before()` → экшен → `after()` |
|
||||||
| `System\Classes\Controller` | Веб-контроллер (extends BaseController): `render()` + авто-CSRF в `before()` + `json()` |
|
| `System\Classes\Controller` | Веб-контроллер (extends BaseController): `render()`/`renderContent()` + авто-CSRF в `before()` + `json()` |
|
||||||
| `System\Classes\View` | Рендеринг `.html`-шаблонов через `ob_start` + `extract` + `include` |
|
| `System\Classes\View` | Рендеринг `.html`-шаблонов через `ob_start` + `extract` + `include`; `setStyle()`/`setScript()`/`setManifest()`+`getStyles()`/`getScripts()` — подключение CSS/JS из шаблона |
|
||||||
|
| `System\Classes\ProfilerToolbar` | Debug-панель внизу страницы (аналог ProfilerToolbar для Kohana): время/память/SQL из `Profiler`; `render()` — пусто, если пользователь не admin |
|
||||||
| `System\Classes\Route` | Автоматический роутинг: парсит URI, ищет контроллер в `App/Controller/` |
|
| `System\Classes\Route` | Автоматический роутинг: парсит URI, ищет контроллер в `App/Controller/` |
|
||||||
| `System\Classes\Request` | Входящий HTTP-запрос; реализует `HTTP\Request`; `post()`, `query()`, `body()`, `isAjax()` |
|
| `System\Classes\Request` | Входящий HTTP-запрос; реализует `HTTP\Request`; `post()`, `query()`, `body()`, `isAjax()` |
|
||||||
| `System\Classes\Response` | HTTP-ответ; `body()` — геттер/сеттер содержимого |
|
| `System\Classes\Response` | HTTP-ответ; `body()` — геттер/сеттер содержимого |
|
||||||
@ -104,6 +109,17 @@ PhpStorm может показывать предупреждение «Namespac
|
|||||||
| `Services\Auth` | Точка входа в авторизацию: `instance(?string $driver = null)`, кэш по драйверу |
|
| `Services\Auth` | Точка входа в авторизацию: `instance(?string $driver = null)`, кэш по драйверу |
|
||||||
| `Services\Auth\AuthDriver` | Интерфейс: `login()`, `logout()`, `loggedIn()`, `getUser()`, `checkPassword()` |
|
| `Services\Auth\AuthDriver` | Интерфейс: `login()`, `logout()`, `loggedIn()`, `getUser()`, `checkPassword()` |
|
||||||
| `Services\Auth\FileAuthDriver` | Авторизация по файлу пользователей (по умолчанию); позже — БД/LDAP/Keycloak |
|
| `Services\Auth\FileAuthDriver` | Авторизация по файлу пользователей (по умолчанию); позже — БД/LDAP/Keycloak |
|
||||||
|
| `Services\Database` | Точка входа в реляционную БД (MariaDB): `instance(?string $name = null): PdoDriver`, кэш по имени подключения |
|
||||||
|
| `Services\DataBase\Classes\PdoDriver` | Обёртка над `PDO`: `query()`/`prepare()`/`exec()`, failover по `hosts`, транзакции с вложенными SAVEPOINT (`transaction()`) |
|
||||||
|
| `Services\DataBase\Classes\Statement` | `extends PDOStatement` (через `PDO::ATTR_STATEMENT_CLASS`); `showQuery()`/`sq()` — SQL с подставленными параметрами, для дебага |
|
||||||
|
| `Services\DataBase\Classes\Profiler` | Статический накопитель таймингов запросов: `log()`, `entries()`, `totalTime()`, `count()`, `reset()` |
|
||||||
|
| `Services\DataBase\Classes\ProfilerPDO` | Адаптер `Profiler` под `PdoDriver::query()`: замеряет время выполнения колбэка |
|
||||||
|
| `Services\DataBase\Model` | Лёгкий пассивный носитель данных строки: `__get`/`__set`/`__isset`/`toArray()` |
|
||||||
|
| `System\Classes\Repository` | Abstract CRUD поверх таблицы (`find/all/insert/update/delete/count/transaction`); конкретные — в `App\Repositories\*` |
|
||||||
|
| `Services\Elasticsearch` | Точка входа в Elasticsearch: `instance(?string $name = null): Client`, кэш по имени подключения |
|
||||||
|
| `Services\Elasticsearch\Client` | REST-обёртка на `HTTP\Client\Curl` (без composer-зависимости `elasticsearch/elasticsearch`): `index()/get()/search()/delete()/exists()/createIndex()/deleteIndex()/ping()` |
|
||||||
|
| `Services\Mongo` | Точка входа в MongoDB: `instance(?string $name = null): MongoDriver`, кэш по имени подключения |
|
||||||
|
| `Services\Mongo\MongoDriver` | Обёртка над `MongoDB\Client` (пакет `mongodb/mongodb`, требует `ext-mongodb`): `client()`, `database()`, `collection()` |
|
||||||
|
|
||||||
### Request lifecycle
|
### Request lifecycle
|
||||||
|
|
||||||
@ -293,6 +309,128 @@ return [
|
|||||||
«Войти» либо «Выйти (логин)» в зависимости от `Auth::instance()->getUser()`; пункт «Админка» виден
|
«Войти» либо «Выйти (логин)» в зависимости от `Auth::instance()->getUser()`; пункт «Админка» виден
|
||||||
только при `role === 'admin'`.
|
только при `role === 'admin'`.
|
||||||
|
|
||||||
|
### DataBase (Services\Database, System\Classes\Repository)
|
||||||
|
|
||||||
|
Реляционная БД (MariaDB) через PDO. Конфиг — `Config::get('db', $name)`, ключ `$name` — это имя
|
||||||
|
*подключения* (не тип драйвера — драйвер сейчас всегда `'pdo'`), по умолчанию `'default'`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Services\Database;
|
||||||
|
|
||||||
|
$driver = Database::instance(); // Config::get('db', 'default')
|
||||||
|
$stmt = $driver->query('SELECT * FROM users WHERE id = ?', [42]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
|
||||||
|
echo $stmt->sq(); // SQL с подставленными параметрами — для дебага (Statement::showQuery())
|
||||||
|
```
|
||||||
|
|
||||||
|
Подключение — ленивое (первое обращение к `pdo()`/`query()`), с failover: `connection.hosts`
|
||||||
|
(массив) перебирается по порядку до первого успешного, иначе — `MyException` со списком ошибок
|
||||||
|
по каждому хосту. Транзакции на уровне `PdoDriver` — вложенные через SAVEPOINT
|
||||||
|
(`beginTransaction()`/`commit()`/`rollback()` считают уровень вложенности сами), либо через обёртку:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$driver->transaction(function ($driver) {
|
||||||
|
$driver->query('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, 1]);
|
||||||
|
$driver->query('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, 2]);
|
||||||
|
});
|
||||||
|
// исключение внутри колбэка → rollback (или ROLLBACK TO SAVEPOINT на вложенном уровне) + повторный throw
|
||||||
|
```
|
||||||
|
|
||||||
|
Тайминги запросов — `Services\DataBase\Classes\Profiler::entries()`/`totalTime()` (наполняется
|
||||||
|
автоматически из `PdoDriver::query()` через `ProfilerPDO`).
|
||||||
|
|
||||||
|
**Repository** — `System\Classes\Repository` (`@template T of object`, abstract CRUD), конкретные — в
|
||||||
|
`App/Repositories/*`. Таблица и класс строки задаются через конструктор, не через переопределение
|
||||||
|
свойства:
|
||||||
|
|
||||||
|
```php
|
||||||
|
namespace App\Repositories;
|
||||||
|
use System\Classes\Repository;
|
||||||
|
|
||||||
|
class BudgetRepository extends Repository
|
||||||
|
{
|
||||||
|
public function __construct(?string $connection = null)
|
||||||
|
{
|
||||||
|
parent::__construct('budget', connection: $connection); // + class-string<T> вторым аргументом, по умолчанию Model::class
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$repo = new BudgetRepository(); // подключение — Database::instance()
|
||||||
|
$repo->get(1); // T|false, через fetchObject()
|
||||||
|
$repo->getItemWhere("status = 'open'"); // T|false|null, произвольное условие (без плейсхолдеров)
|
||||||
|
$repo->getList('*', ['status' => 'open']); // T[]|false; $where — 'col' => 'value' (или 'IS NULL'/'IS NOT NULL')
|
||||||
|
$repo->create(['title' => 'Q3', 'amount' => 1000]); // lastInsertId
|
||||||
|
$repo->update(['id' => 1, 'amount' => 1200]); // primary_col обязателен в $data
|
||||||
|
$repo->delete(1);
|
||||||
|
$repo->deleteWhere('status = ?', ['closed']);
|
||||||
|
$repo->transaction(fn () => /* несколько операций одной транзакцией */ null); // begin/commit/rollBack — делегируют в PdoDriver (там же и SAVEPOINT-логика)
|
||||||
|
```
|
||||||
|
|
||||||
|
`public string $primary_col = 'id'` и `public bool $is_auto_increment = true` — переопределяются в
|
||||||
|
наследнике при необходимости. `processData()` (protected) — общая сборка bind-параметров для
|
||||||
|
`create()`/`update()` с автоопределением `PDO::PARAM_*` по типу значения (массив кодируется в JSON).
|
||||||
|
|
||||||
|
`Model` (`Services\DataBase\Model`, `#[AllowDynamicProperties]`) — лёгкий пассивный носитель данных,
|
||||||
|
это класс по умолчанию для `T` в `Repository`. Свойства настоящие (не спрятаны за внутренним массивом) —
|
||||||
|
это принципиально: `PDO::FETCH_CLASS`/`fetchObject()` выставляет их напрямую, **минуя `__set()`**
|
||||||
|
(внутренний механизм PDO, до вызова конструктора), поэтому попытка перехватить запись через `__set()`
|
||||||
|
в скрытый массив не сработает — `toArray()` вернул бы пусто. Не ActiveRecord — чтение/запись в БД
|
||||||
|
делает `Repository`.
|
||||||
|
|
||||||
|
### Elasticsearch (Services\Elasticsearch)
|
||||||
|
|
||||||
|
Тонкая REST-обёртка на `System\Classes\HTTP\Client\Curl` — без тяжёлой composer-зависимости
|
||||||
|
`elasticsearch/elasticsearch`. Конфиг — `Config::get('elasticsearch', $name)` (`base_url`, `timeout`,
|
||||||
|
опционально `username`/`password` для Basic Auth).
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Services\Elasticsearch;
|
||||||
|
|
||||||
|
$client = Elasticsearch::instance();
|
||||||
|
$client->index('logs', ['level' => 'error', 'message' => 'boom'], 'log-1');
|
||||||
|
$client->get('logs', 'log-1');
|
||||||
|
$client->search('logs', ['query' => ['match' => ['message' => 'boom']]]);
|
||||||
|
$client->delete('logs', 'log-1');
|
||||||
|
$client->ping(); // false вместо исключения, если кластер недоступен — удобно для graceful skip
|
||||||
|
```
|
||||||
|
|
||||||
|
Контейнер `elasticsearch` в текущей dev-инфраструктуре не поднят (см. `docker-dev/docker-compose.yml`) —
|
||||||
|
`ElasticsearchClientTest` пропускает тесты через `markTestSkipped()`, если `ping()` вернул `false`;
|
||||||
|
оживает сам, без правки кода, как только контейнер появится.
|
||||||
|
|
||||||
|
### Mongo (Services\Mongo)
|
||||||
|
|
||||||
|
Обёртка над `MongoDB\Client` (пакет `mongodb/mongodb`, уже в `composer.json`). **Требует PHP-расширение
|
||||||
|
`ext-mongodb`**, которого пока нет в `docker/php-apache/8.2.8/Dockerfile` (общий для нескольких проектов —
|
||||||
|
трогать его нельзя, см. раздел Apache; расширение добавляет пользователь сам). Код безопасен без
|
||||||
|
расширения — падает только при реальном `new MongoDB\Client()` внутри `MongoDriver::__construct()`,
|
||||||
|
не при простом подключении файла/автозагрузке, поэтому `Mongo::instance()` не вызывать эагерно из
|
||||||
|
bootstrap. Конфиг — `Config::get('mongo', $name)` (`uri`, `database`):
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Services\Mongo;
|
||||||
|
|
||||||
|
$driver = Mongo::instance();
|
||||||
|
$driver->collection('logs')->insertOne(['level' => 'error']);
|
||||||
|
$driver->database()->listCollections();
|
||||||
|
```
|
||||||
|
|
||||||
|
`MongoDriverTest` пропускает тесты через `markTestSkipped()`, если `!extension_loaded('mongodb')` —
|
||||||
|
оживает сам, без правки кода, как только пользователь добавит расширение и поднимет контейнер `mongo`.
|
||||||
|
|
||||||
|
### ProfilerToolbar (System\Classes\ProfilerToolbar)
|
||||||
|
|
||||||
|
Debug-панель внизу страницы (аналог [ProfilerToolbar для Kohana](https://github.com/Alert/profilertoolbar)):
|
||||||
|
время выполнения запроса, пиковая память, список SQL-запросов с параметрами (из
|
||||||
|
`Services\DataBase\Classes\Profiler`, см. раздел DataBase выше). Подключена прямо в `App/view/layout.html`
|
||||||
|
(`<?= \System\Classes\ProfilerToolbar::render() ?>` перед `</body>`) — рендерится для любой страницы
|
||||||
|
сайта, но `render()` отдаёт пустую строку всем, кроме `role === 'admin'` (проверка через
|
||||||
|
`Auth::instance()->getUser()`, как и остальные ролевые проверки в проекте). Кликабельная строка сворачивает/
|
||||||
|
разворачивает таблицу запросов (инлайн `onclick`, без внешнего JS/CSS — панель самодостаточна).
|
||||||
|
|
||||||
### Log (System\Classes\Log)
|
### Log (System\Classes\Log)
|
||||||
|
|
||||||
Файловый логгер в стиле `Config`/`Cookie`. Пишет в два файла, именованных датой:
|
Файловый логгер в стиле `Config`/`Cookie`. Пишет в два файла, именованных датой:
|
||||||
@ -363,16 +501,38 @@ $response = HTTPException::factory(403)->getResponse();
|
|||||||
- `$_paths` инициализируется лениво при первом вызове `findFile()`
|
- `$_paths` инициализируется лениво при первом вызове `findFile()`
|
||||||
- `Controller::render()` автоматически определяет `$dir` из имени класса (`App\Controller\FooController` → `view/Foo`)
|
- `Controller::render()` автоматически определяет `$dir` из имени класса (`App\Controller\FooController` → `view/Foo`)
|
||||||
|
|
||||||
|
### Собственные CSS/JS во view (System\Classes\View)
|
||||||
|
|
||||||
|
Файлы — в `App/media/css/` и `App/media/js/`. Content-шаблон подключает свои прямо в себе:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
$this->setStyle('about.css');
|
||||||
|
$this->setScript('about.js');
|
||||||
|
?>
|
||||||
|
```
|
||||||
|
|
||||||
|
`layout.html` выводит накопленное — `<?php $this->getStyles(); ?>` в `<head>`, `<?php $this->getScripts(); ?>`
|
||||||
|
перед `</body>` (есть также `setManifest()` для PWA-манифеста, выводится в `getStyles()`). `$this` внутри
|
||||||
|
шаблона — это объект `View`, который его рендерит (шаблон — `include` внутри `View::render()`), поэтому
|
||||||
|
методы вызываются как `$this->...`, как и `CSRF`/`View`-хелперы в других шаблонах.
|
||||||
|
|
||||||
|
Хранилище (`$_styles`/`$_scripts`/`$_manifest`) — статическое, общее на весь запрос: content и layout —
|
||||||
|
разные объекты `View` (content рендерится первым внутри `Controller::render()`, до создания layout-View),
|
||||||
|
но регистрация в content должна быть видна при выводе в layout. Файл ищется через `Core::findFile()`
|
||||||
|
(`APPPATH` → `SYSPATH`) — если не найден, в месте вызова `setStyle()`/`setScript()`/`setManifest()`
|
||||||
|
выводится `<div class="alert alert-danger">`.
|
||||||
|
|
||||||
### Controller hierarchy
|
### Controller hierarchy
|
||||||
|
|
||||||
Расстановка имён — как в проекте eoffice_v3:
|
Расстановка имён — как в проекте eoffice_v3:
|
||||||
|
|
||||||
- `System\Classes\BaseController` (abstract) — голое ядро: жизненный цикл `executeAction()` (`before()` → экшен → `after()`), хуки по умолчанию пустые. Нейтрально к вебу/API.
|
- `System\Classes\BaseController` (abstract) — голое ядро: жизненный цикл `executeAction()` (`before()` → экшен → `after()`), хуки по умолчанию пустые. Нейтрально к вебу/API.
|
||||||
- `System\Classes\Controller extends BaseController` — веб: `render()` (layout + content), авто-CSRF в `before()`, `json($data, $status)` для JSON-ответов, хуки `layoutData()` (доп. данные в layout помимо `content`; по умолчанию `['menu' => $this->menu()]`) и `menu()` (пункты главного меню сайта, активный — по текущему URI). Layout — `App/view/layout.html` (Bootstrap-навбар сверху с меню сайта, `$content` внутри `<main>`; CSS/JS — Bootstrap, Bootstrap Icons, jQuery из `/vendor/`). Единый для всего сайта, включая админку. **Контроллеры приложения наследуют `Controller` напрямую.**
|
- `System\Classes\Controller extends BaseController` — веб: `render()` (layout + верхнее/боковое меню + content) и `renderContent()` (только content, без layout и меню — для ajax-фрагментов), авто-CSRF в `before()`, `json($data, $status)` для JSON-ответов. Меню — отдельные хуки: `menuTop()`/`menuSide()` отдают пункты (массив `[['title','url','active'], ...]`), `renderMenuTop()`/`renderMenuSide()` рендерят их через `App/view/menu_top.html`/`menu_side.html` в готовый HTML *до* передачи в layout (`renderMenuSide()` — пустая строка, если пунктов нет). Оба хука — общие для всего сайта (не переопределяются в конкретных контроллерах), активный пункт — по текущему URI, видны на любой странице любому пользователю независимо от роли/авторизации (сейчас в `menuSide()` заглушка — «Главная», «О нас», «Новости»; для последних двух заведены `AboutController`/`NewsController`). Layout — `App/view/layout.html`: верхнее меню всегда, боковое — колонка `aside` (Bootstrap grid) появляется, только если `$menu_side` не пуст; иначе `content` на всю ширину `<main>`. Никакой проверки `isAjax()` нет — экшен сам решает, вызывать `render()` или `renderContent()`. CSS/JS — Bootstrap, Bootstrap Icons, jQuery из `/vendor/`. Единый layout для всего сайта, включая админку. **Контроллеры приложения наследуют `Controller` напрямую.**
|
||||||
|
|
||||||
API-контроллер делается не отдельным классом, а флагом: `extends Controller` + `$_csrf_protection = false` + ответы через `json()` (так же, как в eoffice_v3).
|
API-контроллер делается не отдельным классом, а флагом: `extends Controller` + `$_csrf_protection = false` + ответы через `json()` (так же, как в eoffice_v3).
|
||||||
|
|
||||||
**Админка** (`App/Controller/Admin/`): базовый `App\Controller\Admin\AdminController extends Controller` своего layout/меню не задаёт — рендерится в том же `App/view/layout.html`, что и весь сайт (в меню есть пункт «Админка»). Включает `$_auth_protection = true` — доступ только авторизованным (см. [Auth](#auth-servicesauth)), неавторизованный редиректится на `/login`. Конкретные страницы (`LogsController` и т.п.) наследуют `AdminController`.
|
**Админка** (`App/Controller/Admin/`): базовый `App\Controller\Admin\AdminController extends Controller` своего layout и меню не задаёт — рендерится в том же `App/view/layout.html`, что и весь сайт, с тем же общим `Controller::menuSide()` (не связан с админкой — заглушка на весь сайт). Включает `$_auth_protection = true` — доступ только авторизованным (см. [Auth](#auth-servicesauth)), неавторизованный редиректится на `/login`. Конкретные страницы (`LogsController` и т.п.) наследуют `AdminController`.
|
||||||
|
|
||||||
`Request::execute()` вызывает `executeAction({action}Action)`, поэтому `before()/after()` работают для любого контроллера прозрачно. Авторизация конкретного контроллера включается/выключается флагом `$_auth_protection` (см. раздел Auth), а не переопределением `before()` вручную.
|
`Request::execute()` вызывает `executeAction({action}Action)`, поэтому `before()/after()` работают для любого контроллера прозрачно. Авторизация конкретного контроллера включается/выключается флагом `$_auth_protection` (см. раздел Auth), а не переопределением `before()` вручную.
|
||||||
|
|
||||||
@ -400,7 +560,7 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap
|
|||||||
| `tests/Unit/MyExceptionTest.php` | `strtr` замена, code, previous |
|
| `tests/Unit/MyExceptionTest.php` | `strtr` замена, code, previous |
|
||||||
| `tests/Unit/CoreTest.php` | `findFile()`, `getConst()`, environment |
|
| `tests/Unit/CoreTest.php` | `findFile()`, `getConst()`, environment |
|
||||||
| `tests/Unit/RequestTest.php` | `factory()`, `post()`, `query()`, `body()`, `isAjax()`, `method()` |
|
| `tests/Unit/RequestTest.php` | `factory()`, `post()`, `query()`, `body()`, `isAjax()`, `method()` |
|
||||||
| `tests/Unit/ViewTest.php` | `render()`, `setFilename()`, data extract |
|
| `tests/Unit/ViewTest.php` | `render()`, `setFilename()`, data extract, `setStyle()`/`setScript()`/`setManifest()` + `getStyles()`/`getScripts()` |
|
||||||
| `tests/Unit/HttpClientRequestTest.php` | `HTTP\Client\Request` builder, json, fluent |
|
| `tests/Unit/HttpClientRequestTest.php` | `HTTP\Client\Request` builder, json, fluent |
|
||||||
| `tests/Unit/CookieTest.php` | `get()`, `set()`, `delete()`, defaults |
|
| `tests/Unit/CookieTest.php` | `get()`, `set()`, `delete()`, defaults |
|
||||||
| `tests/Unit/SessionTest.php` | singleton, `get/set/delete/destroy/regenerate/bind` |
|
| `tests/Unit/SessionTest.php` | singleton, `get/set/delete/destroy/regenerate/bind` |
|
||||||
@ -413,8 +573,19 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap
|
|||||||
| `tests/Unit/FileLogReaderTest.php` | парсинг, фильтры (level/q/channel), newest-first, missing file, `dates()` |
|
| `tests/Unit/FileLogReaderTest.php` | парсинг, фильтры (level/q/channel), newest-first, missing file, `dates()` |
|
||||||
| `tests/Unit/FileAuthDriverTest.php` | `login()` верно/неверно/неизвестный логин, `loggedIn()`, `getUser()` без пароля, `logout()`, `checkPassword()`, отсутствие файла |
|
| `tests/Unit/FileAuthDriverTest.php` | `login()` верно/неверно/неизвестный логин, `loggedIn()`, `getUser()` без пароля, `logout()`, `checkPassword()`, отсутствие файла |
|
||||||
| `tests/Unit/AuthTest.php` | `instance()` драйвер по умолчанию/явный, кэширование по драйверу, неизвестный драйвер → исключение |
|
| `tests/Unit/AuthTest.php` | `instance()` драйвер по умолчанию/явный, кэширование по драйверу, неизвестный драйвер → исключение |
|
||||||
|
| `tests/Unit/DatabaseTest.php` | `instance()` кэш по имени подключения, неизвестное подключение/драйвер → исключение |
|
||||||
|
| `tests/Unit/PdoDriverTest.php` | failover (не требует БД), `query()`, транзакции/вложенные SAVEPOINT, `transaction()` — требует живую MariaDB, иначе `markTestSkipped()` |
|
||||||
|
| `tests/Unit/StatementTest.php` | `showQuery()`/`sq()` — подстановка позиционных/именованных параметров — требует живую MariaDB |
|
||||||
|
| `tests/Unit/ProfilerTest.php` | `log()/entries()/totalTime()/count()/reset()`, интеграция через `ProfilerPDO::wrap()` |
|
||||||
|
| `tests/Unit/RepositoryTest.php` | CRUD, `all()` с `where`, `transaction()` с rollback — требует живую MariaDB |
|
||||||
|
| `tests/Unit/ModelTest.php` | `__get/__set/__isset/toArray()` |
|
||||||
|
| `tests/Unit/ElasticsearchTest.php` | `instance()` кэш по имени подключения, неизвестное подключение → исключение |
|
||||||
|
| `tests/Unit/ElasticsearchClientTest.php` | `index()/get()/delete()/exists()` — требует живой Elasticsearch, иначе `markTestSkipped()` через `ping()` |
|
||||||
|
| `tests/Unit/MongoTest.php` | неизвестное подключение → исключение (без ext-mongodb — остальное см. MongoDriverTest) |
|
||||||
|
| `tests/Unit/MongoDriverTest.php` | `instance()`, `collection()`, `database()` — требует `ext-mongodb`, иначе `markTestSkipped()` |
|
||||||
|
| `tests/Unit/ProfilerToolbarTest.php` | `render()` пусто для гостя/не-admin, панель с временем/памятью/SQL для admin |
|
||||||
|
|
||||||
**177 тестов, 290 assertion — все проходят.**
|
**233 теста, 335 assertion — все проходят (26 skipped: MariaDB/Elasticsearch/Mongo без живой инфраструктуры — см. разделы DataBase/Elasticsearch/Mongo выше).**
|
||||||
|
|
||||||
### Frontend dependencies (через Composer)
|
### Frontend dependencies (через Composer)
|
||||||
|
|
||||||
|
|||||||
217
Services/DataBase/Classes/PdoDriver.php
Normal file
217
Services/DataBase/Classes/PdoDriver.php
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description PdoDriver.php
|
||||||
|
* @copyright (c) 07/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Services\DataBase\Classes;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
use PDOStatement;
|
||||||
|
use System\Classes\MyException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обёртка над PDO: ленивое подключение (с перебором хостов при отказе — failover),
|
||||||
|
* запросы через Statement (см. Statement::showQuery()) и вложенные транзакции через SAVEPOINT.
|
||||||
|
*/
|
||||||
|
class PdoDriver
|
||||||
|
{
|
||||||
|
/** @var PDO|null Подключение; null до первого обращения (connect()/pdo()) */
|
||||||
|
protected ?PDO $_pdo = null;
|
||||||
|
|
||||||
|
/** @var int Текущий уровень вложенности транзакции (0 — вне транзакции) */
|
||||||
|
protected int $_transaction_level = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array $config Параметры подключения: host|hosts, dbname, user, password, charset (по умолчанию utf8mb4)
|
||||||
|
*/
|
||||||
|
public function __construct(protected array $config)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подключается к первому доступному хосту из host|hosts (failover).
|
||||||
|
* Не делает ничего, если подключение уже установлено.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
* @throws MyException Если не удалось подключиться ни к одному хосту
|
||||||
|
*/
|
||||||
|
public function connect(): void
|
||||||
|
{
|
||||||
|
if ($this->_pdo !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hosts = $this->config['hosts'] ?? [$this->config['host'] ?? 'localhost'];
|
||||||
|
$dbname = $this->config['dbname'] ?? '';
|
||||||
|
$user = $this->config['user'] ?? '';
|
||||||
|
$password = $this->config['password'] ?? '';
|
||||||
|
$charset = $this->config['charset'] ?? 'utf8mb4';
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
foreach ($hosts as $host) {
|
||||||
|
$dsn = "mysql:host=$host;dbname=$dbname;charset=$charset";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new PDO($dsn, $user, $password, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
]);
|
||||||
|
$pdo->setAttribute(PDO::ATTR_STATEMENT_CLASS, [Statement::class, [$this]]);
|
||||||
|
|
||||||
|
$this->_pdo = $pdo;
|
||||||
|
|
||||||
|
return;
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$errors[] = "$host: {$e->getMessage()}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new MyException(
|
||||||
|
'Не удалось подключиться ни к одному хосту БД: :errors', [':errors' => implode('; ', $errors)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return PDO Подключается при первом обращении
|
||||||
|
* @throws MyException
|
||||||
|
*/
|
||||||
|
public function pdo(): PDO
|
||||||
|
{
|
||||||
|
$this->connect();
|
||||||
|
|
||||||
|
return $this->_pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Готовит выражение (через Statement — см. PDO::ATTR_STATEMENT_CLASS).
|
||||||
|
*
|
||||||
|
* @param string $sql SQL-запрос
|
||||||
|
* @return false|PDOStatement
|
||||||
|
* @throws MyException
|
||||||
|
*/
|
||||||
|
public function prepare(string $sql): false|PDOStatement
|
||||||
|
{
|
||||||
|
return $this->pdo()->prepare($sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Готовит, выполняет (с профилированием времени — см. ProfilerPDO) и возвращает выражение.
|
||||||
|
*
|
||||||
|
* @param string $sql SQL-запрос
|
||||||
|
* @param array $params Параметры для execute()
|
||||||
|
* @return false|PDOStatement
|
||||||
|
* @throws MyException
|
||||||
|
*/
|
||||||
|
public function query(string $sql, array $params = []): false|PDOStatement
|
||||||
|
{
|
||||||
|
$statement = $this->prepare($sql);
|
||||||
|
|
||||||
|
ProfilerPDO::wrap($sql, $params, static fn () => $statement->execute($params));
|
||||||
|
|
||||||
|
return $statement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $sql SQL-запрос
|
||||||
|
* @return int Число задетых строк
|
||||||
|
* @throws MyException
|
||||||
|
*/
|
||||||
|
public function exec(string $sql): int
|
||||||
|
{
|
||||||
|
return $this->pdo()->exec($sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Начинает транзакцию; на вложенном уровне — SAVEPOINT.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
* @throws MyException
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
* @throws MyException
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
* @throws MyException
|
||||||
|
*/
|
||||||
|
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 на одном PdoDriver) используют SAVEPOINT прозрачно.
|
||||||
|
*
|
||||||
|
* @param callable $callback
|
||||||
|
* @return mixed Результат $callback
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string|false ID последней вставленной строки
|
||||||
|
* @throws MyException
|
||||||
|
*/
|
||||||
|
public function lastInsertId(): string|false
|
||||||
|
{
|
||||||
|
return $this->pdo()->lastInsertId();
|
||||||
|
}
|
||||||
|
}
|
||||||
68
Services/DataBase/Classes/Profiler.php
Normal file
68
Services/DataBase/Classes/Profiler.php
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description Profiler.php
|
||||||
|
* @copyright (c) 07/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Services\DataBase\Classes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Накопитель таймингов запросов к БД — статический реестр на весь запрос
|
||||||
|
* (по образцу Log). Backend-agnostic сам по себе (принимает произвольный $driver),
|
||||||
|
* заполняется адаптерами вроде ProfilerPDO.
|
||||||
|
*/
|
||||||
|
class Profiler
|
||||||
|
{
|
||||||
|
/** @var array<int,array{sql:string,params:array,time_ms:float,driver:string}> */
|
||||||
|
private static array $_entries = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавляет запись о выполненном запросе.
|
||||||
|
*
|
||||||
|
* @param string $sql SQL-запрос
|
||||||
|
* @param array $params Параметры запроса
|
||||||
|
* @param float $time_ms Время выполнения в миллисекундах
|
||||||
|
* @param string $driver Имя драйвера (для отчёта, если профилируются разные бэкенды)
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function log(string $sql, array $params, float $time_ms, string $driver = 'pdo'): void
|
||||||
|
{
|
||||||
|
self::$_entries[] = ['sql' => $sql, 'params' => $params, 'time_ms' => $time_ms, 'driver' => $driver];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,array{sql:string,params:array,time_ms:float,driver:string}>
|
||||||
|
*/
|
||||||
|
public static function entries(): array
|
||||||
|
{
|
||||||
|
return self::$_entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return float Суммарное время всех залогированных запросов, мс
|
||||||
|
*/
|
||||||
|
public static function totalTime(): float
|
||||||
|
{
|
||||||
|
return array_sum(array_column(self::$_entries, 'time_ms'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return int Число залогированных запросов
|
||||||
|
*/
|
||||||
|
public static function count(): int
|
||||||
|
{
|
||||||
|
return count(self::$_entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Очищает накопленные записи.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function reset(): void
|
||||||
|
{
|
||||||
|
self::$_entries = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
34
Services/DataBase/Classes/ProfilerPDO.php
Normal file
34
Services/DataBase/Classes/ProfilerPDO.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description ProfilerPDO.php
|
||||||
|
* @copyright (c) 07/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Services\DataBase\Classes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Адаптер Profiler под PdoDriver::query() — замеряет время выполнения $execute
|
||||||
|
* и логирует запись в Profiler.
|
||||||
|
*/
|
||||||
|
class ProfilerPDO
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Выполняет $execute с замером времени, логирует в Profiler и возвращает результат.
|
||||||
|
*
|
||||||
|
* @param string $sql SQL-запрос (для записи в профилировщик)
|
||||||
|
* @param array $params Параметры запроса (для записи в профилировщик)
|
||||||
|
* @param callable $execute Колбэк, выполняющий сам запрос
|
||||||
|
* @return mixed Результат $execute
|
||||||
|
*/
|
||||||
|
public static function wrap(string $sql, array $params, callable $execute): mixed
|
||||||
|
{
|
||||||
|
$start = microtime(true);
|
||||||
|
$result = $execute();
|
||||||
|
|
||||||
|
Profiler::log($sql, $params, (microtime(true) - $start) * 1000);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
96
Services/DataBase/Classes/Statement.php
Normal file
96
Services/DataBase/Classes/Statement.php
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description Statement.php
|
||||||
|
* @copyright (c) 07/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Services\DataBase\Classes;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use PDOStatement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расширение PDOStatement, подключается через PDO::ATTR_STATEMENT_CLASS
|
||||||
|
* (см. PdoDriver::connect()). Добавляет showQuery()/sq() — PDOStatement::debugDumpParams()
|
||||||
|
* печатает в stdout, а не возвращает строку, поэтому значения bind-параметров
|
||||||
|
* копятся вручную (bindValue()/execute() переопределены) и подставляются в SQL сами.
|
||||||
|
*/
|
||||||
|
class Statement extends PDOStatement
|
||||||
|
{
|
||||||
|
/** @var array<int|string,mixed> Значения, забинженные через bindValue()/execute() */
|
||||||
|
protected array $_bound_params = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конструктор вызывается только изнутри PDO (PDO::ATTR_STATEMENT_CLASS) — не создавать напрямую.
|
||||||
|
*
|
||||||
|
* @param PdoDriver $driver Владелец, нужен для quote() в showQuery()
|
||||||
|
*/
|
||||||
|
protected function __construct(protected PdoDriver $driver)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function bindValue(int|string $param, mixed $value, int $type = PDO::PARAM_STR): bool
|
||||||
|
{
|
||||||
|
$this->_bound_params[$param] = $value;
|
||||||
|
|
||||||
|
return parent::bindValue($param, $value, $type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function execute(?array $params = null): bool
|
||||||
|
{
|
||||||
|
foreach ($params ?? [] as $key => $value) {
|
||||||
|
$this->_bound_params[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::execute($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает SQL с подставленными вместо плейсхолдеров значениями — только для дебага
|
||||||
|
* (не для реального выполнения: значения квотируются через PDO::quote(), не защищают от всего,
|
||||||
|
* на что рассчитана честная подготовленная подстановка).
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
* @throws \System\Classes\MyException
|
||||||
|
*/
|
||||||
|
public function showQuery(): string
|
||||||
|
{
|
||||||
|
$sql = $this->queryString;
|
||||||
|
|
||||||
|
foreach ($this->_bound_params as $key => $value) {
|
||||||
|
$quoted = is_int($value) || is_float($value) ? (string) $value : $this->driver->pdo()->quote((string) $value);
|
||||||
|
|
||||||
|
if (is_int($key)) {
|
||||||
|
$pos = strpos($sql, '?');
|
||||||
|
|
||||||
|
if ($pos !== false) {
|
||||||
|
$sql = substr_replace($sql, $quoted, $pos, 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$placeholder = str_starts_with((string) $key, ':') ? $key : ':' . $key;
|
||||||
|
$sql = str_replace($placeholder, $quoted, $sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sql;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Алиас showQuery() для быстрого дебага.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
* @throws \System\Classes\MyException
|
||||||
|
*/
|
||||||
|
public function sq(): string
|
||||||
|
{
|
||||||
|
return $this->showQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
58
Services/DataBase/Model.php
Normal file
58
Services/DataBase/Model.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description Model.php
|
||||||
|
* @copyright (c) 07/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Services\DataBase;
|
||||||
|
|
||||||
|
use AllowDynamicProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Лёгкий пассивный носитель данных одной строки — не ActiveRecord: чтение/запись
|
||||||
|
* в БД делает Repository. Свойства настоящие (не спрятаны за внутренним массивом),
|
||||||
|
* потому что PDO::FETCH_CLASS/fetchObject() выставляет их напрямую, минуя __set()
|
||||||
|
* (это внутренний механизм PDO, работает до вызова конструктора) — #[AllowDynamicProperties]
|
||||||
|
* нужен, чтобы это не ловило deprecation в PHP 8.2+.
|
||||||
|
*/
|
||||||
|
#[AllowDynamicProperties]
|
||||||
|
class Model
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $name Имя свойства
|
||||||
|
* @param mixed $value Значение
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function __set(string $name, mixed $value): void
|
||||||
|
{
|
||||||
|
$this->$name = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $name Имя свойства
|
||||||
|
* @return mixed null, если свойства нет
|
||||||
|
*/
|
||||||
|
public function __get(string $name): mixed
|
||||||
|
{
|
||||||
|
return $this->$name ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $name Имя свойства
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function __isset(string $name): bool
|
||||||
|
{
|
||||||
|
return isset($this->$name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed> Все свойства
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return (array) $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
Services/Database.php
Normal file
58
Services/Database.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description Database.php
|
||||||
|
* @copyright (c) 07/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Services;
|
||||||
|
|
||||||
|
use Services\DataBase\Classes\PdoDriver;
|
||||||
|
use System\Classes\Config;
|
||||||
|
use System\Classes\MyException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Точка входа в реляционную БД. Имя аргумента — это имя подключения
|
||||||
|
* (ключ в Config::get('db', $name), по умолчанию 'default'), а не тип драйвера —
|
||||||
|
* сейчас есть только один драйвер ('pdo'), поэтому реестр по типу драйвера
|
||||||
|
* (как в Auth::$_drivers) не заводится, пока не появится второй.
|
||||||
|
*
|
||||||
|
* Database::instance()->query('SELECT ...');
|
||||||
|
* Database::instance('reporting')->query(...); // отдельное подключение
|
||||||
|
*/
|
||||||
|
class Database
|
||||||
|
{
|
||||||
|
/** @var array<string,PdoDriver> Кэш подключений по имени */
|
||||||
|
protected static array $_instances = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает подключение по имени (кэшируется).
|
||||||
|
*
|
||||||
|
* @param string|null $name Имя подключения из конфига; null — 'default'
|
||||||
|
* @return PdoDriver
|
||||||
|
* @throws MyException Если подключение не описано в конфиге или драйвер неизвестен
|
||||||
|
*/
|
||||||
|
public static function instance(?string $name = null): PdoDriver
|
||||||
|
{
|
||||||
|
$name ??= 'default';
|
||||||
|
|
||||||
|
if (!isset(self::$_instances[$name])) {
|
||||||
|
$config = Config::get('db', $name);
|
||||||
|
|
||||||
|
if ($config === null) {
|
||||||
|
throw new MyException('Подключение к БД не описано в конфиге: :name', [':name' => $name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($config['driver'] ?? null) !== 'pdo') {
|
||||||
|
throw new MyException(
|
||||||
|
'Неизвестный драйвер БД: :driver', [':driver' => $config['driver'] ?? 'null']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$_instances[$name] = new PdoDriver($config['connection'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$_instances[$name];
|
||||||
|
}
|
||||||
|
}
|
||||||
47
Services/Elasticsearch.php
Normal file
47
Services/Elasticsearch.php
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description Elasticsearch.php
|
||||||
|
* @copyright (c) 07/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Services;
|
||||||
|
|
||||||
|
use Services\Elasticsearch\Client;
|
||||||
|
use System\Classes\Config;
|
||||||
|
use System\Classes\MyException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Точка входа в Elasticsearch. Имя аргумента — имя подключения
|
||||||
|
* (ключ в Config::get('elasticsearch', $name), по умолчанию 'default').
|
||||||
|
*
|
||||||
|
* Elasticsearch::instance()->search('logs', ['query' => ['match_all' => new stdClass()]]);
|
||||||
|
*/
|
||||||
|
class Elasticsearch
|
||||||
|
{
|
||||||
|
/** @var array<string,Client> Кэш клиентов по имени подключения */
|
||||||
|
protected static array $_instances = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|null $name Имя подключения из конфига; null — 'default'
|
||||||
|
* @return Client
|
||||||
|
* @throws MyException Если подключение не описано в конфиге
|
||||||
|
*/
|
||||||
|
public static function instance(?string $name = null): Client
|
||||||
|
{
|
||||||
|
$name ??= 'default';
|
||||||
|
|
||||||
|
if (!isset(self::$_instances[$name])) {
|
||||||
|
$config = Config::get('elasticsearch', $name);
|
||||||
|
|
||||||
|
if ($config === null) {
|
||||||
|
throw new MyException('Подключение к Elasticsearch не описано в конфиге: :name', [':name' => $name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$_instances[$name] = new Client($config);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$_instances[$name];
|
||||||
|
}
|
||||||
|
}
|
||||||
152
Services/Elasticsearch/Client.php
Normal file
152
Services/Elasticsearch/Client.php
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description Client.php
|
||||||
|
* @copyright (c) 07/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Services\Elasticsearch;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
use System\Classes\HTTP\Client\Curl;
|
||||||
|
use System\Classes\HTTP\Client\Request as HttpRequest;
|
||||||
|
use System\Classes\HTTP\Client\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тонкая обёртка над Elasticsearch REST API поверх System\Classes\HTTP\Client\Curl —
|
||||||
|
* без тяжёлой composer-зависимости elasticsearch/elasticsearch, только базовые операции
|
||||||
|
* (индексация/поиск/чтение/удаление документа, создание/удаление индекса).
|
||||||
|
*/
|
||||||
|
class Client
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array $config base_url, timeout (сек, по умолчанию 10), username/password (Basic auth, опционально)
|
||||||
|
*/
|
||||||
|
public function __construct(protected array $config)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Индексирует документ. Без $id — Elasticsearch сгенерирует его сам (POST);
|
||||||
|
* с $id — документ по этому id создаётся/перезаписывается (PUT).
|
||||||
|
*
|
||||||
|
* @param string $index Имя индекса
|
||||||
|
* @param array $document Тело документа
|
||||||
|
* @param string|null $id ID документа
|
||||||
|
* @return array Ответ Elasticsearch (декодированный JSON)
|
||||||
|
* @throws \JsonException
|
||||||
|
*/
|
||||||
|
public function index(string $index, array $document, ?string $id = null): array
|
||||||
|
{
|
||||||
|
$path = $id !== null ? "/$index/_doc/" . rawurlencode($id) : "/$index/_doc";
|
||||||
|
$method = $id !== null ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
return $this->request($method, $path, $document)->json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $index Имя индекса
|
||||||
|
* @param string $id ID документа
|
||||||
|
* @return array|null null, если документ не найден (404)
|
||||||
|
* @throws \JsonException
|
||||||
|
*/
|
||||||
|
public function get(string $index, string $id): ?array
|
||||||
|
{
|
||||||
|
$response = $this->request('GET', "/$index/_doc/" . rawurlencode($id));
|
||||||
|
|
||||||
|
return $response->status() === 404 ? null : $response->json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $index Имя индекса
|
||||||
|
* @param string $id ID документа
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function exists(string $index, string $id): bool
|
||||||
|
{
|
||||||
|
return $this->request('GET', "/$index/_doc/" . rawurlencode($id))->status() === 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $index Имя индекса
|
||||||
|
* @param array $query Elasticsearch Query DSL, например ['query' => ['match' => ['title' => 'foo']]]
|
||||||
|
* @return array Ответ Elasticsearch (декодированный JSON)
|
||||||
|
* @throws \JsonException
|
||||||
|
*/
|
||||||
|
public function search(string $index, array $query): array
|
||||||
|
{
|
||||||
|
return $this->request('POST', "/$index/_search", $query)->json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $index Имя индекса
|
||||||
|
* @param string $id ID документа
|
||||||
|
* @return bool true, если удалён
|
||||||
|
*/
|
||||||
|
public function delete(string $index, string $id): bool
|
||||||
|
{
|
||||||
|
return $this->request('DELETE', "/$index/_doc/" . rawurlencode($id))->isSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $index Имя индекса
|
||||||
|
* @param array $body settings/mappings (опционально)
|
||||||
|
* @return array Ответ Elasticsearch (декодированный JSON)
|
||||||
|
* @throws \JsonException
|
||||||
|
*/
|
||||||
|
public function createIndex(string $index, array $body = []): array
|
||||||
|
{
|
||||||
|
return $this->request('PUT', "/$index", $body ?: null)->json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $index Имя индекса
|
||||||
|
* @return bool true, если удалён
|
||||||
|
*/
|
||||||
|
public function deleteIndex(string $index): bool
|
||||||
|
{
|
||||||
|
return $this->request('DELETE', "/$index")->isSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет доступность кластера. Не бросает исключение при недоступности — просто false
|
||||||
|
* (удобно для graceful skip в тестах/коде, когда Elasticsearch может быть не поднят).
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function ping(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->request('GET', '/')->isSuccess();
|
||||||
|
} catch (RuntimeException) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $method HTTP-метод
|
||||||
|
* @param string $path Путь относительно base_url
|
||||||
|
* @param array|null $body Тело запроса (JSON), null — без тела
|
||||||
|
* @return Response
|
||||||
|
*/
|
||||||
|
protected function request(string $method, string $path, ?array $body = null): Response
|
||||||
|
{
|
||||||
|
$http_request = HttpRequest::factory(rtrim($this->config['base_url'] ?? '', '/') . $path)
|
||||||
|
->method($method)
|
||||||
|
->timeout($this->config['timeout'] ?? 10);
|
||||||
|
|
||||||
|
if ($body !== null) {
|
||||||
|
$http_request->json($body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($this->config['username'])) {
|
||||||
|
$http_request->header(
|
||||||
|
'Authorization',
|
||||||
|
'Basic ' . base64_encode($this->config['username'] . ':' . ($this->config['password'] ?? ''))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new Curl())->execute($http_request);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
Services/Mongo.php
Normal file
52
Services/Mongo.php
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description Mongo.php
|
||||||
|
* @copyright (c) 07/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Services;
|
||||||
|
|
||||||
|
use Services\Mongo\MongoDriver;
|
||||||
|
use System\Classes\Config;
|
||||||
|
use System\Classes\MyException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Точка входа в MongoDB. Имя аргумента — имя подключения
|
||||||
|
* (ключ в Config::get('mongo', $name), по умолчанию 'default').
|
||||||
|
*
|
||||||
|
* Требует ext-mongodb + composer-пакет mongodb/mongodb — но само по себе подключение
|
||||||
|
* этого файла и вызов instance() безопасны без расширения: реальная зависимость
|
||||||
|
* (new MongoDB\Client()) наступает только внутри MongoDriver, и только при первом
|
||||||
|
* реальном обращении (см. MongoDriver).
|
||||||
|
*
|
||||||
|
* Mongo::instance()->collection('logs')->insertOne([...]);
|
||||||
|
*/
|
||||||
|
class Mongo
|
||||||
|
{
|
||||||
|
/** @var array<string,MongoDriver> Кэш подключений по имени */
|
||||||
|
protected static array $_instances = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|null $name Имя подключения из конфига; null — 'default'
|
||||||
|
* @return MongoDriver
|
||||||
|
* @throws MyException Если подключение не описано в конфиге
|
||||||
|
*/
|
||||||
|
public static function instance(?string $name = null): MongoDriver
|
||||||
|
{
|
||||||
|
$name ??= 'default';
|
||||||
|
|
||||||
|
if (!isset(self::$_instances[$name])) {
|
||||||
|
$config = Config::get('mongo', $name);
|
||||||
|
|
||||||
|
if ($config === null) {
|
||||||
|
throw new MyException('Подключение к MongoDB не описано в конфиге: :name', [':name' => $name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$_instances[$name] = new MongoDriver($config);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$_instances[$name];
|
||||||
|
}
|
||||||
|
}
|
||||||
67
Services/Mongo/MongoDriver.php
Normal file
67
Services/Mongo/MongoDriver.php
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description MongoDriver.php
|
||||||
|
* @copyright (c) 07/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Services\Mongo;
|
||||||
|
|
||||||
|
use MongoDB\Client;
|
||||||
|
use MongoDB\Collection;
|
||||||
|
use MongoDB\Database;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обёртка над MongoDB\Client (пакет mongodb/mongodb, требует ext-mongodb).
|
||||||
|
* Использование классов MongoDB\* в сигнатурах ниже само по себе безопасно без
|
||||||
|
* расширения — PHP резолвит имя класса лениво, при реальном обращении к значению,
|
||||||
|
* а не при подключении файла. Единственное место, где расширение реально нужно —
|
||||||
|
* __construct(): new Client(...) упадёт в \Error, если ext-mongodb не установлен.
|
||||||
|
* Поэтому Mongo::instance() не вызывать эагерно из bootstrap/Core::init() —
|
||||||
|
* только там, где Mongo реально нужен.
|
||||||
|
*/
|
||||||
|
class MongoDriver
|
||||||
|
{
|
||||||
|
/** @var Client */
|
||||||
|
protected Client $_client;
|
||||||
|
|
||||||
|
/** @var string Имя БД по умолчанию (см. database()/collection()) */
|
||||||
|
protected string $_database;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array $config uri (например 'mongodb://mongo:27017'), database
|
||||||
|
*/
|
||||||
|
public function __construct(array $config)
|
||||||
|
{
|
||||||
|
$this->_client = new Client($config['uri'] ?? 'mongodb://localhost:27017');
|
||||||
|
$this->_database = $config['database'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Client
|
||||||
|
*/
|
||||||
|
public function client(): Client
|
||||||
|
{
|
||||||
|
return $this->_client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|null $name null — имя БД из конфига
|
||||||
|
* @return Database
|
||||||
|
*/
|
||||||
|
public function database(?string $name = null): Database
|
||||||
|
{
|
||||||
|
return $this->_client->selectDatabase($name ?? $this->_database);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $name Имя коллекции
|
||||||
|
* @param string|null $database null — имя БД из конфига
|
||||||
|
* @return Collection
|
||||||
|
*/
|
||||||
|
public function collection(string $name, ?string $database = null): Collection
|
||||||
|
{
|
||||||
|
return $this->_client->selectCollection($database ?? $this->_database, $name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -39,7 +39,7 @@ class Controller extends BaseController
|
|||||||
* 403 — при недостаточной роли).
|
* 403 — при недостаточной роли).
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
* @throws HTTPException 403, если CSRF-токен не прошёл или роль не подходит
|
* @throws HTTPException|MyException 403, если CSRF-токен не прошёл или роль не подходит
|
||||||
*/
|
*/
|
||||||
protected function before(): void
|
protected function before(): void
|
||||||
{
|
{
|
||||||
@ -70,9 +70,7 @@ class Controller extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Рендерит шаблон контента внутри layout.
|
* Рендерит шаблон контента внутри layout вместе с верхним и боковым меню.
|
||||||
* Если $dir пуст — определяется автоматически из имени класса
|
|
||||||
* (App\Controller\FooController → view/Foo).
|
|
||||||
*
|
*
|
||||||
* @param string $template Имя шаблона контента без расширения
|
* @param string $template Имя шаблона контента без расширения
|
||||||
* @param array $data Данные, передаваемые в шаблон
|
* @param array $data Данные, передаваемые в шаблон
|
||||||
@ -81,35 +79,68 @@ class Controller extends BaseController
|
|||||||
* @throws MyException
|
* @throws MyException
|
||||||
*/
|
*/
|
||||||
protected function render(string $template, array $data = [], string $dir = ''): string
|
protected function render(string $template, array $data = [], string $dir = ''): string
|
||||||
|
{
|
||||||
|
return (new View($this->_layout, 'view', [
|
||||||
|
'menu_top' => $this->renderMenuTop(),
|
||||||
|
'menu_side' => $this->renderMenuSide(),
|
||||||
|
'content' => $this->renderContent($template, $data, $dir),
|
||||||
|
]))->render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит шаблон контента без layout — просто view, без меню и обвязки.
|
||||||
|
* Если $dir пуст — определяется автоматически из имени класса
|
||||||
|
* (App\Controller\FooController → view/Foo). Пригодится для ajax-фрагментов:
|
||||||
|
* экшен сам решает, вызывать render() или renderContent(), без проверки isAjax().
|
||||||
|
*
|
||||||
|
* @param string $template Имя шаблона контента без расширения
|
||||||
|
* @param array $data Данные, передаваемые в шаблон
|
||||||
|
* @param string $dir Каталог шаблона относительно view/ (опционально)
|
||||||
|
* @return string Готовый HTML фрагмента
|
||||||
|
* @throws MyException
|
||||||
|
*/
|
||||||
|
protected function renderContent(string $template, array $data = [], string $dir = ''): string
|
||||||
{
|
{
|
||||||
if ($dir === '') {
|
if ($dir === '') {
|
||||||
$class = substr(get_class($this), strlen('App\\Controller\\')); // [Admin\]FooController
|
$class = substr(get_class($this), strlen('App\\Controller\\')); // [Admin\]FooController
|
||||||
$dir = 'view/' . str_replace('\\', '/', substr($class, 0, -10)); // view/[Admin/]Foo
|
$dir = 'view/' . str_replace('\\', '/', substr($class, 0, -10)); // view/[Admin/]Foo
|
||||||
}
|
}
|
||||||
|
|
||||||
return (new View($this->_layout, 'view', [
|
return (new View($template, $dir, $data))->render();
|
||||||
'content' => (new View($template, $dir, $data))->render(),
|
|
||||||
] + $this->layoutData()))->render();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Дополнительные данные, передаваемые в layout помимо 'content'.
|
* HTML верхнего меню сайта — рендерит {@see menuTop()} через шаблон view/menu_top.
|
||||||
* По умолчанию — пункты главного меню; наследники могут переопределить
|
|
||||||
* полностью (как AdminController — своим боковым меню).
|
|
||||||
*
|
*
|
||||||
* @return array
|
* @return string
|
||||||
|
* @throws MyException
|
||||||
*/
|
*/
|
||||||
protected function layoutData(): array
|
protected function renderMenuTop(): string
|
||||||
{
|
{
|
||||||
return ['menu' => $this->menu()];
|
return (new View('menu_top', 'view', ['menu' => $this->menuTop()]))->render();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Пункты главного меню сайта. Активный пункт — по текущему URI.
|
* HTML бокового меню раздела — рендерит {@see menuSide()} через шаблон view/menu_side.
|
||||||
|
* Пустая строка, если пунктов нет (по умолчанию бокового меню нет вовсе;
|
||||||
|
* layout.html в этом случае не рисует колонку сайдбара).
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
* @throws MyException
|
||||||
|
*/
|
||||||
|
protected function renderMenuSide(): string
|
||||||
|
{
|
||||||
|
$items = $this->menuSide();
|
||||||
|
|
||||||
|
return $items ? (new View('menu_side', 'view', ['menu' => $items]))->render() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пункты верхнего меню сайта. Активный пункт — по текущему URI.
|
||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
protected function menu(): array
|
protected function menuTop(): array
|
||||||
{
|
{
|
||||||
$uri = Request::$current?->uri() ?? '';
|
$uri = Request::$current?->uri() ?? '';
|
||||||
$user = Auth::instance()->getUser();
|
$user = Auth::instance()->getUser();
|
||||||
@ -133,12 +164,36 @@ class Controller extends BaseController
|
|||||||
return $items;
|
return $items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пункты бокового меню — общие для всего сайта (как и menuTop()), видны
|
||||||
|
* на любой странице любому пользователю, независимо от роли и авторизации.
|
||||||
|
* Активный пункт — по текущему URI.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
protected function menuSide(): array
|
||||||
|
{
|
||||||
|
$uri = Request::$current?->uri() ?? '';
|
||||||
|
$items = [
|
||||||
|
['title' => 'Главная', 'url' => '/'],
|
||||||
|
['title' => 'О нас', 'url' => '/about'],
|
||||||
|
['title' => 'Новости', 'url' => '/news'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($items as &$item) {
|
||||||
|
$item['active'] = trim($item['url'], '/') === $uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Формирует JSON-ответ: ставит статус и Content-Type, кодирует данные.
|
* Формирует JSON-ответ: ставит статус и Content-Type, кодирует данные.
|
||||||
*
|
*
|
||||||
* @param mixed $data Данные ответа
|
* @param mixed $data Данные ответа
|
||||||
* @param int $status HTTP-статус
|
* @param int $status HTTP-статус
|
||||||
* @return string JSON
|
* @return string JSON
|
||||||
|
* @throws \JsonException
|
||||||
*/
|
*/
|
||||||
protected function json(mixed $data, int $status = 200): string
|
protected function json(mixed $data, int $status = 200): string
|
||||||
{
|
{
|
||||||
|
|||||||
91
System/Classes/ProfilerToolbar.php
Normal file
91
System/Classes/ProfilerToolbar.php
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description ProfilerToolbar.php
|
||||||
|
* @copyright (c) 07/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace System\Classes;
|
||||||
|
|
||||||
|
use Services\Auth;
|
||||||
|
use Services\DataBase\Classes\Profiler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug-панель внизу страницы: время запроса, память, SQL-запросы
|
||||||
|
* (см. Services\DataBase\Classes\Profiler). Видна только role === 'admin'
|
||||||
|
* (см. Services\Auth) — render() отдаёт пустую строку всем остальным.
|
||||||
|
* Аналог ProfilerToolbar для Kohana (github.com/Alert/profilertoolbar).
|
||||||
|
*/
|
||||||
|
class ProfilerToolbar
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Рендерит панель, если текущий пользователь — admin, иначе — пустую строку.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function render(): string
|
||||||
|
{
|
||||||
|
$user = Auth::instance()->getUser();
|
||||||
|
|
||||||
|
if (($user['role'] ?? null) !== 'admin') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$time_ms = (microtime(true) - ($_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true))) * 1000;
|
||||||
|
$memory = memory_get_peak_usage(true);
|
||||||
|
$query_count = Profiler::count();
|
||||||
|
$query_time = Profiler::totalTime();
|
||||||
|
|
||||||
|
$rows = '';
|
||||||
|
foreach (Profiler::entries() as $entry) {
|
||||||
|
$rows .= '<tr>' .
|
||||||
|
'<td>' . number_format($entry['time_ms'], 2) . ' ms</td>' .
|
||||||
|
'<td><code>' . htmlspecialchars($entry['sql'], ENT_QUOTES, 'UTF-8') . '</code></td>' .
|
||||||
|
'<td><code>' . htmlspecialchars(json_encode($entry['params'], JSON_UNESCAPED_UNICODE), ENT_QUOTES, 'UTF-8') . '</code></td>' .
|
||||||
|
'</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::html($time_ms, $memory, $query_count, $query_time, $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param float $time_ms Время выполнения запроса, мс
|
||||||
|
* @param int $memory Пиковое потребление памяти, байт
|
||||||
|
* @param int $query_count Число SQL-запросов
|
||||||
|
* @param float $query_time Суммарное время SQL-запросов, мс
|
||||||
|
* @param string $rows Готовые <tr> со списком запросов
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private static function html(float $time_ms, int $memory, int $query_count, float $query_time, string $rows): string
|
||||||
|
{
|
||||||
|
$memory_mb = number_format($memory / 1024 / 1024, 2);
|
||||||
|
$time = number_format($time_ms, 1);
|
||||||
|
$query_ms = number_format($query_time, 1);
|
||||||
|
|
||||||
|
return <<<HTML
|
||||||
|
<div id="profiler-toolbar" style="position:fixed;left:0;right:0;bottom:0;z-index:99999;
|
||||||
|
font:12px/1.4 monospace;background:#1b1b1b;color:#ddd;border-top:2px solid #444;">
|
||||||
|
<div style="display:flex;gap:16px;align-items:center;padding:4px 12px;cursor:pointer;"
|
||||||
|
onclick="var b=document.getElementById('profiler-toolbar-body');b.style.display=b.style.display==='none'?'block':'none';">
|
||||||
|
<strong style="color:#8bc34a;">Profiler</strong>
|
||||||
|
<span>время: {$time} ms</span>
|
||||||
|
<span>память: {$memory_mb} MB</span>
|
||||||
|
<span>SQL: {$query_count} ({$query_ms} ms)</span>
|
||||||
|
</div>
|
||||||
|
<div id="profiler-toolbar-body" style="display:none;max-height:300px;overflow:auto;border-top:1px solid #444;">
|
||||||
|
<table style="width:100%;border-collapse:collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr style="text-align:left;color:#999;">
|
||||||
|
<th style="padding:4px 12px;">Время</th>
|
||||||
|
<th style="padding:4px 12px;">SQL</th>
|
||||||
|
<th style="padding:4px 12px;">Параметры</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{$rows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
325
System/Classes/Repository.php
Normal file
325
System/Classes/Repository.php
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,6 +25,22 @@ class View
|
|||||||
*/
|
*/
|
||||||
public array $_data = [];
|
public array $_data = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int,string> Зарегистрированные пути CSS-файлов — общие для всех View за
|
||||||
|
* один запрос (content рендерится раньше layout, см. Controller::render())
|
||||||
|
*/
|
||||||
|
private static array $_styles = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int,array{src:string,type:string}> Зарегистрированные JS-файлы
|
||||||
|
*/
|
||||||
|
private static array $_scripts = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string|null Путь к зарегистрированному manifest-файлу
|
||||||
|
*/
|
||||||
|
private static ?string $_manifest = null;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string|null $file Имя файла шаблона без расширения
|
* @param string|null $file Имя файла шаблона без расширения
|
||||||
@ -95,4 +111,100 @@ class View
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Регистрирует CSS-файл из App/media/css — вызывается прямо из шаблона:
|
||||||
|
* <?php $this->setStyle('about.css'); ?>. Выводится позже через getStyles()
|
||||||
|
* (обычно из layout.html, после того как content уже отрендерен).
|
||||||
|
*
|
||||||
|
* @param string $file Имя файла с расширением, например 'about.css'
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function setStyle(string $file): void
|
||||||
|
{
|
||||||
|
$name = pathinfo($file, PATHINFO_FILENAME);
|
||||||
|
|
||||||
|
if (Core::findFile('media/css', $name, 'css') === false) {
|
||||||
|
echo '<div class="alert alert-danger" role="alert">Файл \'' .
|
||||||
|
htmlspecialchars($file, ENT_QUOTES, 'UTF-8') . '\' в media/css не найден</div>';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$_styles[] = "/App/media/css/$name.css";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Регистрирует JS-файл из App/media/js — вызывается прямо из шаблона:
|
||||||
|
* <?php $this->setScript('about.js'); ?>. Выводится позже через getScripts().
|
||||||
|
*
|
||||||
|
* @param string $file Имя файла с расширением, например 'about.js'
|
||||||
|
* @param string $type Атрибут type у <script>
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function setScript(string $file, string $type = 'text/javascript'): void
|
||||||
|
{
|
||||||
|
$name = pathinfo($file, PATHINFO_FILENAME);
|
||||||
|
|
||||||
|
if (Core::findFile('media/js', $name, 'js') === false) {
|
||||||
|
echo '<div class="alert alert-danger" role="alert">Файл \'' .
|
||||||
|
htmlspecialchars($file, ENT_QUOTES, 'UTF-8') . '\' в media/js не найден</div>';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$_scripts[] = ['src' => "/App/media/js/$name.js", 'type' => $type];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Регистрирует manifest (PWA) из App/media — <?php $this->setManifest('manifest.json'); ?>.
|
||||||
|
*
|
||||||
|
* @param string $file Имя файла, например 'manifest.json' или 'manifest.webmanifest'
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function setManifest(string $file): void
|
||||||
|
{
|
||||||
|
$name = pathinfo($file, PATHINFO_FILENAME);
|
||||||
|
$ext = pathinfo($file, PATHINFO_EXTENSION) ?: 'json';
|
||||||
|
|
||||||
|
if (Core::findFile('media', $name, $ext) === false) {
|
||||||
|
echo '<div class="alert alert-danger" role="alert">Файл \'' .
|
||||||
|
htmlspecialchars($file, ENT_QUOTES, 'UTF-8') . '\' в media не найден</div>';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$_manifest = "/App/media/$name.$ext";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выводит <link> для manifest и всех зарегистрированных стилей — вызывать в <head> layout.html:
|
||||||
|
* <?php $this->getStyles(); ?>.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function getStyles(): void
|
||||||
|
{
|
||||||
|
if (self::$_manifest !== null) {
|
||||||
|
echo '<link rel="manifest" href="' . htmlspecialchars(self::$_manifest, ENT_QUOTES, 'UTF-8') . "\">\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::$_styles as $href) {
|
||||||
|
echo '<link rel="stylesheet" href="' . htmlspecialchars($href, ENT_QUOTES, 'UTF-8') . "\">\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выводит <script> для всех зарегистрированных скриптов — вызывать перед </body> layout.html:
|
||||||
|
* <?php $this->getScripts(); ?>.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function getScripts(): void
|
||||||
|
{
|
||||||
|
foreach (self::$_scripts as $script) {
|
||||||
|
echo '<script type="' . htmlspecialchars($script['type'], ENT_QUOTES, 'UTF-8') . '" src="' .
|
||||||
|
htmlspecialchars($script['src'], ENT_QUOTES, 'UTF-8') . "\"></script>\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.2",
|
"php": ">=8.2",
|
||||||
"ext-pdo": "*",
|
"ext-pdo": "*",
|
||||||
|
"ext-pdo_mysql": "*",
|
||||||
"phpoffice/phpspreadsheet": "5.*",
|
"phpoffice/phpspreadsheet": "5.*",
|
||||||
"phpoffice/phpword": "1.1.0",
|
"phpoffice/phpword": "1.1.0",
|
||||||
"phpmailer/phpmailer": "v6.9.*",
|
"phpmailer/phpmailer": "v6.9.*",
|
||||||
@ -10,7 +11,8 @@
|
|||||||
"select2/select2": "4.1.*",
|
"select2/select2": "4.1.*",
|
||||||
"components/jquery": "v3.7.1",
|
"components/jquery": "v3.7.1",
|
||||||
"components/jqueryui": "1.12.*",
|
"components/jqueryui": "1.12.*",
|
||||||
"dompdf/dompdf": "v3.1.*"
|
"dompdf/dompdf": "v3.1.*",
|
||||||
|
"mongodb/mongodb": "^2.3"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^11.0"
|
"phpunit/phpunit": "^11.0"
|
||||||
@ -31,6 +33,9 @@
|
|||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
"php-http/discovery": true,
|
"php-http/discovery": true,
|
||||||
"composer/installers": true
|
"composer/installers": true
|
||||||
|
},
|
||||||
|
"platform": {
|
||||||
|
"ext-mongodb": "2.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
351
composer.lock
generated
351
composer.lock
generated
@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "d15667ff3f0882b8e8daa05eae634821",
|
"content-hash": "d9e5b024bdd073ae913e3de7dc02e71e",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "components/jquery",
|
"name": "components/jquery",
|
||||||
@ -146,28 +146,29 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "composer/pcre",
|
"name": "composer/pcre",
|
||||||
"version": "3.3.2",
|
"version": "3.4.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/composer/pcre.git",
|
"url": "https://github.com/composer/pcre.git",
|
||||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
|
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
"url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
|
||||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^7.4 || ^8.0"
|
"php": "^7.4 || ^8.0"
|
||||||
},
|
},
|
||||||
"conflict": {
|
"conflict": {
|
||||||
"phpstan/phpstan": "<1.11.10"
|
"phpstan/phpstan": "<2.2.2"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpstan/phpstan": "^1.12 || ^2",
|
"phpstan/phpstan": "^2",
|
||||||
"phpstan/phpstan-strict-rules": "^1 || ^2",
|
"phpstan/phpstan-deprecation-rules": "^2",
|
||||||
"phpunit/phpunit": "^8 || ^9"
|
"phpstan/phpstan-strict-rules": "^2",
|
||||||
|
"phpunit/phpunit": "^9"
|
||||||
},
|
},
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
@ -205,7 +206,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/composer/pcre/issues",
|
"issues": "https://github.com/composer/pcre/issues",
|
||||||
"source": "https://github.com/composer/pcre/tree/3.3.2"
|
"source": "https://github.com/composer/pcre/tree/3.4.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@ -215,26 +216,22 @@
|
|||||||
{
|
{
|
||||||
"url": "https://github.com/composer",
|
"url": "https://github.com/composer",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2024-11-12T16:29:46+00:00"
|
"time": "2026-06-07T11:47:49+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "dompdf/dompdf",
|
"name": "dompdf/dompdf",
|
||||||
"version": "v3.1.5",
|
"version": "v3.1.6",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/dompdf/dompdf.git",
|
"url": "https://github.com/dompdf/dompdf.git",
|
||||||
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496"
|
"reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
|
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/6d4b4eb8500f7a786da8868ba463a71b725a4005",
|
||||||
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
|
"reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@ -283,9 +280,9 @@
|
|||||||
"homepage": "https://github.com/dompdf/dompdf",
|
"homepage": "https://github.com/dompdf/dompdf",
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/dompdf/dompdf/issues",
|
"issues": "https://github.com/dompdf/dompdf/issues",
|
||||||
"source": "https://github.com/dompdf/dompdf/tree/v3.1.5"
|
"source": "https://github.com/dompdf/dompdf/tree/v3.1.6"
|
||||||
},
|
},
|
||||||
"time": "2026-03-03T13:54:37+00:00"
|
"time": "2026-07-20T12:29:38+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "dompdf/php-font-lib",
|
"name": "dompdf/php-font-lib",
|
||||||
@ -626,16 +623,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "masterminds/html5",
|
"name": "masterminds/html5",
|
||||||
"version": "2.10.0",
|
"version": "2.10.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/Masterminds/html5-php.git",
|
"url": "https://github.com/Masterminds/html5-php.git",
|
||||||
"reference": "fcf91eb64359852f00d921887b219479b4f21251"
|
"reference": "fd5018f6815fff903946d0564977b44ce8010e29"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
|
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29",
|
||||||
"reference": "fcf91eb64359852f00d921887b219479b4f21251",
|
"reference": "fd5018f6815fff903946d0564977b44ce8010e29",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@ -643,7 +640,7 @@
|
|||||||
"php": ">=5.3.0"
|
"php": ">=5.3.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9"
|
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10"
|
||||||
},
|
},
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
@ -687,9 +684,82 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/Masterminds/html5-php/issues",
|
"issues": "https://github.com/Masterminds/html5-php/issues",
|
||||||
"source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
|
"source": "https://github.com/Masterminds/html5-php/tree/2.10.1"
|
||||||
},
|
},
|
||||||
"time": "2025-07-25T09:04:22+00:00"
|
"time": "2026-06-23T18:43:15+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mongodb/mongodb",
|
||||||
|
"version": "2.3.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/mongodb/mongo-php-library",
|
||||||
|
"reference": "12e56461166d915e3dd8e4969ad0940ed5207d88"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/12e56461166d915e3dd8e4969ad0940ed5207d88",
|
||||||
|
"reference": "12e56461166d915e3dd8e4969ad0940ed5207d88",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer-runtime-api": "^2.0",
|
||||||
|
"ext-mongodb": "^2.3",
|
||||||
|
"php": "^8.1",
|
||||||
|
"psr/log": "^1.1.4|^2|^3",
|
||||||
|
"symfony/polyfill-php85": "^1.33"
|
||||||
|
},
|
||||||
|
"replace": {
|
||||||
|
"mongodb/builder": "*"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"doctrine/coding-standard": "^12.0",
|
||||||
|
"phpunit/phpunit": "^10.5.35",
|
||||||
|
"rector/rector": "^2.3.4",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7",
|
||||||
|
"vimeo/psalm": "~6.14.2"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "1.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/functions.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"MongoDB\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"Apache-2.0"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Andreas Braun",
|
||||||
|
"email": "andreas.braun@mongodb.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jeremy Mikola",
|
||||||
|
"email": "jmikola@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jérôme Tamarelle",
|
||||||
|
"email": "jerome.tamarelle@mongodb.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "MongoDB driver library",
|
||||||
|
"homepage": "https://jira.mongodb.org/browse/PHPLIB",
|
||||||
|
"keywords": [
|
||||||
|
"database",
|
||||||
|
"driver",
|
||||||
|
"mongodb",
|
||||||
|
"persistence"
|
||||||
|
],
|
||||||
|
"time": "2026-04-28T12:50:06+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpmailer/phpmailer",
|
"name": "phpmailer/phpmailer",
|
||||||
@ -774,16 +844,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpoffice/phpspreadsheet",
|
"name": "phpoffice/phpspreadsheet",
|
||||||
"version": "5.7.0",
|
"version": "5.9.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
||||||
"reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8"
|
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
|
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/05e99ebf61238a70227b4d9cc02d0030d34f6339",
|
||||||
"reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
|
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@ -805,7 +875,7 @@
|
|||||||
"maennchen/zipstream-php": "^2.1 || ^3.0",
|
"maennchen/zipstream-php": "^2.1 || ^3.0",
|
||||||
"markbaker/complex": "^3.0",
|
"markbaker/complex": "^3.0",
|
||||||
"markbaker/matrix": "^3.0",
|
"markbaker/matrix": "^3.0",
|
||||||
"php": "^8.1",
|
"php": "^8.2",
|
||||||
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
@ -819,7 +889,7 @@
|
|||||||
"phpstan/phpstan": "^1.1 || ^2.0",
|
"phpstan/phpstan": "^1.1 || ^2.0",
|
||||||
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
|
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
|
||||||
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
|
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
|
||||||
"phpunit/phpunit": "^10.5",
|
"phpunit/phpunit": "^10.5 || ^11.0",
|
||||||
"squizlabs/php_codesniffer": "^3.7",
|
"squizlabs/php_codesniffer": "^3.7",
|
||||||
"tecnickcom/tcpdf": "^6.5"
|
"tecnickcom/tcpdf": "^6.5"
|
||||||
},
|
},
|
||||||
@ -877,9 +947,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
||||||
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.7.0"
|
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.9.0"
|
||||||
},
|
},
|
||||||
"time": "2026-04-20T02:42:17+00:00"
|
"time": "2026-07-12T19:17:39+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpoffice/phpword",
|
"name": "phpoffice/phpword",
|
||||||
@ -989,6 +1059,56 @@
|
|||||||
},
|
},
|
||||||
"time": "2023-05-30T07:59:14+00:00"
|
"time": "2023-05-30T07:59:14+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "psr/log",
|
||||||
|
"version": "3.0.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/php-fig/log.git",
|
||||||
|
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
|
||||||
|
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Psr\\Log\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "PHP-FIG",
|
||||||
|
"homepage": "https://www.php-fig.org/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Common interface for logging libraries",
|
||||||
|
"homepage": "https://github.com/php-fig/log",
|
||||||
|
"keywords": [
|
||||||
|
"log",
|
||||||
|
"psr",
|
||||||
|
"psr-3"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/php-fig/log/tree/3.0.2"
|
||||||
|
},
|
||||||
|
"time": "2024-09-11T13:17:53+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "psr/simple-cache",
|
"name": "psr/simple-cache",
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
@ -1042,16 +1162,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sabberworm/php-css-parser",
|
"name": "sabberworm/php-css-parser",
|
||||||
"version": "v9.3.0",
|
"version": "v9.4.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
|
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
|
||||||
"reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949"
|
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949",
|
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
|
||||||
"reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949",
|
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@ -1062,15 +1182,15 @@
|
|||||||
"require-dev": {
|
"require-dev": {
|
||||||
"php-parallel-lint/php-parallel-lint": "1.4.0",
|
"php-parallel-lint/php-parallel-lint": "1.4.0",
|
||||||
"phpstan/extension-installer": "1.4.3",
|
"phpstan/extension-installer": "1.4.3",
|
||||||
"phpstan/phpstan": "1.12.32 || 2.1.32",
|
"phpstan/phpstan": "1.12.33 || 2.2.2",
|
||||||
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.8",
|
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.16",
|
||||||
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7",
|
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11",
|
||||||
"phpunit/phpunit": "8.5.52",
|
"phpunit/phpunit": "8.5.52",
|
||||||
"rawr/phpunit-data-provider": "3.3.1",
|
"rawr/phpunit-data-provider": "3.3.1",
|
||||||
"rector/rector": "1.2.10 || 2.2.8",
|
"rector/rector": "1.2.10 || 2.4.6",
|
||||||
"rector/type-perfect": "1.0.0 || 2.1.0",
|
"rector/type-perfect": "1.0.0 || 2.1.3",
|
||||||
"squizlabs/php_codesniffer": "4.0.1",
|
"squizlabs/php_codesniffer": "4.0.1",
|
||||||
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1"
|
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3"
|
||||||
},
|
},
|
||||||
"suggest": {
|
"suggest": {
|
||||||
"ext-mbstring": "for parsing UTF-8 CSS"
|
"ext-mbstring": "for parsing UTF-8 CSS"
|
||||||
@ -1078,7 +1198,7 @@
|
|||||||
"type": "library",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
"branch-alias": {
|
"branch-alias": {
|
||||||
"dev-main": "9.4.x-dev"
|
"dev-main": "9.5.x-dev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
@ -1116,9 +1236,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
|
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
|
||||||
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0"
|
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0"
|
||||||
},
|
},
|
||||||
"time": "2026-03-03T17:31:43+00:00"
|
"time": "2026-06-18T15:10:53+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "select2/select2",
|
"name": "select2/select2",
|
||||||
@ -1172,6 +1292,86 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-05-26T20:03:12+00:00"
|
"time": "2026-05-26T20:03:12+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/polyfill-php85",
|
||||||
|
"version": "v1.41.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/polyfill-php85.git",
|
||||||
|
"reference": "255fab485aaa1006ed411040c42aecd7b5302d7a"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a",
|
||||||
|
"reference": "255fab485aaa1006ed411040c42aecd7b5302d7a",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=7.2"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"thanks": {
|
||||||
|
"url": "https://github.com/symfony/polyfill",
|
||||||
|
"name": "symfony/polyfill"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"bootstrap.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Polyfill\\Php85\\": ""
|
||||||
|
},
|
||||||
|
"classmap": [
|
||||||
|
"Resources/stubs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Nicolas Grekas",
|
||||||
|
"email": "p@tchwork.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"compatibility",
|
||||||
|
"polyfill",
|
||||||
|
"portable",
|
||||||
|
"shim"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-07-01T12:47:55+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "thecodingmachine/safe",
|
"name": "thecodingmachine/safe",
|
||||||
"version": "v3.4.0",
|
"version": "v3.4.0",
|
||||||
@ -1468,20 +1668,19 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "nikic/php-parser",
|
"name": "nikic/php-parser",
|
||||||
"version": "v5.7.0",
|
"version": "v5.8.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/nikic/PHP-Parser.git",
|
"url": "https://github.com/nikic/PHP-Parser.git",
|
||||||
"reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
|
"reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
|
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
|
||||||
"reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
|
"reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"ext-ctype": "*",
|
|
||||||
"ext-json": "*",
|
"ext-json": "*",
|
||||||
"ext-tokenizer": "*",
|
"ext-tokenizer": "*",
|
||||||
"php": ">=7.4"
|
"php": ">=7.4"
|
||||||
@ -1520,9 +1719,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/nikic/PHP-Parser/issues",
|
"issues": "https://github.com/nikic/PHP-Parser/issues",
|
||||||
"source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
|
"source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0"
|
||||||
},
|
},
|
||||||
"time": "2025-12-06T11:56:16+00:00"
|
"time": "2026-07-04T14:30:18+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phar-io/manifest",
|
"name": "phar-io/manifest",
|
||||||
@ -1991,24 +2190,24 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/phpunit",
|
"name": "phpunit/phpunit",
|
||||||
"version": "11.5.55",
|
"version": "11.5.56",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||||
"reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00"
|
"reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00",
|
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5f83edffa6967c3db468d48a695ec7bcb02e9256",
|
||||||
"reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00",
|
"reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"ext-dom": "*",
|
"ext-dom": "*",
|
||||||
|
"ext-filter": "*",
|
||||||
"ext-json": "*",
|
"ext-json": "*",
|
||||||
"ext-libxml": "*",
|
"ext-libxml": "*",
|
||||||
"ext-mbstring": "*",
|
"ext-mbstring": "*",
|
||||||
"ext-xml": "*",
|
|
||||||
"ext-xmlwriter": "*",
|
"ext-xmlwriter": "*",
|
||||||
"myclabs/deep-copy": "^1.13.4",
|
"myclabs/deep-copy": "^1.13.4",
|
||||||
"phar-io/manifest": "^2.0.4",
|
"phar-io/manifest": "^2.0.4",
|
||||||
@ -2073,31 +2272,15 @@
|
|||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55"
|
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.56"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"url": "https://phpunit.de/sponsors.html",
|
"url": "https://phpunit.de/sponsoring.html",
|
||||||
"type": "custom"
|
"type": "other"
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/sebastianbergmann",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://liberapay.com/sebastianbergmann",
|
|
||||||
"type": "liberapay"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://thanks.dev/u/gh/sebastianbergmann",
|
|
||||||
"type": "thanks_dev"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-02-18T12:37:06+00:00"
|
"time": "2026-07-06T14:52:39+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sebastian/cli-parser",
|
"name": "sebastian/cli-parser",
|
||||||
@ -3195,8 +3378,12 @@
|
|||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": ">=8.2",
|
"php": ">=8.2",
|
||||||
"ext-pdo": "*"
|
"ext-pdo": "*",
|
||||||
|
"ext-pdo_mysql": "*"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
|
"platform-overrides": {
|
||||||
|
"ext-mongodb": "2.3"
|
||||||
|
},
|
||||||
"plugin-api-version": "2.9.0"
|
"plugin-api-version": "2.9.0"
|
||||||
}
|
}
|
||||||
|
|||||||
61
tests/Unit/DatabaseTest.php
Normal file
61
tests/Unit/DatabaseTest.php
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionClass;
|
||||||
|
use Services\Database;
|
||||||
|
use Services\DataBase\Classes\PdoDriver;
|
||||||
|
use System\Classes\Config;
|
||||||
|
use System\Classes\MyException;
|
||||||
|
|
||||||
|
class DatabaseTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
// Сбрасываем кэш подключений Database и загруженный Config, чтобы тесты не влияли друг на друга.
|
||||||
|
$ref = new ReflectionClass(Database::class);
|
||||||
|
$prop = $ref->getProperty('_instances');
|
||||||
|
$prop->setAccessible(true);
|
||||||
|
$prop->setValue(null, []);
|
||||||
|
|
||||||
|
$ref = new ReflectionClass(Config::class);
|
||||||
|
$prop = $ref->getProperty('config');
|
||||||
|
$prop->setAccessible(true);
|
||||||
|
$prop->setValue(null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDefaultConnectionReturnsPdoDriver(): void
|
||||||
|
{
|
||||||
|
$this->assertInstanceOf(PdoDriver::class, Database::instance());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInstanceIsCachedPerConnectionName(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(Database::instance('default'), Database::instance('default'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDifferentConnectionNamesAreDistinctInstances(): void
|
||||||
|
{
|
||||||
|
Config::set('db', [
|
||||||
|
'default' => ['driver' => 'pdo', 'connection' => ['host' => 'db', 'dbname' => 'a']],
|
||||||
|
'second' => ['driver' => 'pdo', 'connection' => ['host' => 'db', 'dbname' => 'b']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNotSame(Database::instance('default'), Database::instance('second'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownConnectionNameThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(MyException::class);
|
||||||
|
Database::instance('nonexistent');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownDriverThrows(): void
|
||||||
|
{
|
||||||
|
Config::set('db', ['bad' => ['driver' => 'mysqli', 'connection' => []]]);
|
||||||
|
|
||||||
|
$this->expectException(MyException::class);
|
||||||
|
Database::instance('bad');
|
||||||
|
}
|
||||||
|
}
|
||||||
59
tests/Unit/ElasticsearchClientTest.php
Normal file
59
tests/Unit/ElasticsearchClientTest.php
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Services\Elasticsearch\Client;
|
||||||
|
use System\Classes\Config;
|
||||||
|
|
||||||
|
class ElasticsearchClientTest extends TestCase
|
||||||
|
{
|
||||||
|
private Client $client;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->client = new Client(Config::get('elasticsearch', 'default') ?? []);
|
||||||
|
|
||||||
|
if (!$this->client->ping()) {
|
||||||
|
$this->markTestSkipped('Elasticsearch недоступен (контейнер не поднят) — см. docker-dev/docker-compose.yml');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->client->deleteIndex('bicycle_test_index');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
// ping() ещё раз — если setUp() уже пропустил тест через markTestSkipped(),
|
||||||
|
// PHPUnit всё равно вызывает tearDown(), и deleteIndex() упадёт на реальном подключении.
|
||||||
|
if (isset($this->client) && $this->client->ping()) {
|
||||||
|
$this->client->deleteIndex('bicycle_test_index');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIndexAndGetDocument(): void
|
||||||
|
{
|
||||||
|
$this->client->index('bicycle_test_index', ['title' => 'Bicycle'], 'doc1');
|
||||||
|
$document = $this->client->get('bicycle_test_index', 'doc1');
|
||||||
|
|
||||||
|
$this->assertSame('Bicycle', $document['_source']['title']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetMissingDocumentReturnsNull(): void
|
||||||
|
{
|
||||||
|
$this->assertNull($this->client->get('bicycle_test_index', 'nonexistent'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeleteDocument(): void
|
||||||
|
{
|
||||||
|
$this->client->index('bicycle_test_index', ['title' => 'Bicycle'], 'doc1');
|
||||||
|
$this->assertTrue($this->client->delete('bicycle_test_index', 'doc1'));
|
||||||
|
$this->assertNull($this->client->get('bicycle_test_index', 'doc1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExists(): void
|
||||||
|
{
|
||||||
|
$this->client->index('bicycle_test_index', ['title' => 'Bicycle'], 'doc1');
|
||||||
|
$this->assertTrue($this->client->exists('bicycle_test_index', 'doc1'));
|
||||||
|
$this->assertFalse($this->client->exists('bicycle_test_index', 'nonexistent'));
|
||||||
|
}
|
||||||
|
}
|
||||||
42
tests/Unit/ElasticsearchTest.php
Normal file
42
tests/Unit/ElasticsearchTest.php
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionClass;
|
||||||
|
use Services\Elasticsearch;
|
||||||
|
use Services\Elasticsearch\Client;
|
||||||
|
use System\Classes\Config;
|
||||||
|
use System\Classes\MyException;
|
||||||
|
|
||||||
|
class ElasticsearchTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$ref = new ReflectionClass(Elasticsearch::class);
|
||||||
|
$prop = $ref->getProperty('_instances');
|
||||||
|
$prop->setAccessible(true);
|
||||||
|
$prop->setValue(null, []);
|
||||||
|
|
||||||
|
$ref = new ReflectionClass(Config::class);
|
||||||
|
$prop = $ref->getProperty('config');
|
||||||
|
$prop->setAccessible(true);
|
||||||
|
$prop->setValue(null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDefaultConnectionReturnsClient(): void
|
||||||
|
{
|
||||||
|
$this->assertInstanceOf(Client::class, Elasticsearch::instance());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInstanceIsCachedPerConnectionName(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(Elasticsearch::instance('default'), Elasticsearch::instance('default'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownConnectionNameThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(MyException::class);
|
||||||
|
Elasticsearch::instance('nonexistent');
|
||||||
|
}
|
||||||
|
}
|
||||||
64
tests/Unit/ModelTest.php
Normal file
64
tests/Unit/ModelTest.php
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Services\DataBase\Model;
|
||||||
|
|
||||||
|
class ModelTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testGetReturnsAttribute(): void
|
||||||
|
{
|
||||||
|
$model = new Model();
|
||||||
|
$model->name = 'bicycle';
|
||||||
|
|
||||||
|
$this->assertSame('bicycle', $model->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetMissingAttributeReturnsNull(): void
|
||||||
|
{
|
||||||
|
$model = new Model();
|
||||||
|
$this->assertNull($model->missing);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetWritesAttribute(): void
|
||||||
|
{
|
||||||
|
$model = new Model();
|
||||||
|
$model->name = 'bicycle';
|
||||||
|
$this->assertSame('bicycle', $model->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsset(): void
|
||||||
|
{
|
||||||
|
$model = new Model();
|
||||||
|
$model->name = 'bicycle';
|
||||||
|
|
||||||
|
$this->assertTrue(isset($model->name));
|
||||||
|
$this->assertFalse(isset($model->missing));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testToArray(): void
|
||||||
|
{
|
||||||
|
$model = new Model();
|
||||||
|
$model->id = 1;
|
||||||
|
$model->name = 'bicycle';
|
||||||
|
|
||||||
|
$this->assertSame(['id' => 1, 'name' => 'bicycle'], $model->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPdoFetchClassPopulatesRealProperties(): void
|
||||||
|
{
|
||||||
|
// PDO::FETCH_CLASS выставляет свойства напрямую, минуя __set() — проверяем именно это,
|
||||||
|
// не общий __get/__set (см. PHP-документацию по FETCH_CLASS: свойства до конструктора).
|
||||||
|
$pdo = new PDO('sqlite::memory:');
|
||||||
|
$pdo->exec('CREATE TABLE t (id INTEGER, name TEXT)');
|
||||||
|
$pdo->exec("INSERT INTO t (id, name) VALUES (1, 'bicycle')");
|
||||||
|
|
||||||
|
$model = $pdo->query('SELECT * FROM t')->fetchObject(Model::class);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(Model::class, $model);
|
||||||
|
$this->assertSame('bicycle', $model->name);
|
||||||
|
$this->assertSame(['id' => 1, 'name' => 'bicycle'], $model->toArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
53
tests/Unit/MongoDriverTest.php
Normal file
53
tests/Unit/MongoDriverTest.php
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionClass;
|
||||||
|
use Services\Mongo;
|
||||||
|
use Services\Mongo\MongoDriver;
|
||||||
|
use System\Classes\Config;
|
||||||
|
|
||||||
|
class MongoDriverTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
if (!extension_loaded('mongodb')) {
|
||||||
|
$this->markTestSkipped('ext-mongodb не установлен (см. docker/php-apache/8.2.8/Dockerfile)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$ref = new ReflectionClass(Mongo::class);
|
||||||
|
$prop = $ref->getProperty('_instances');
|
||||||
|
$prop->setAccessible(true);
|
||||||
|
$prop->setValue(null, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInstanceReturnsMongoDriver(): void
|
||||||
|
{
|
||||||
|
$this->assertInstanceOf(MongoDriver::class, Mongo::instance());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInstanceIsCachedPerConnectionName(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(Mongo::instance('default'), Mongo::instance('default'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCollectionReturnsMongoCollection(): void
|
||||||
|
{
|
||||||
|
$config = Config::get('mongo', 'default');
|
||||||
|
$driver = new MongoDriver($config);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(\MongoDB\Collection::class, $driver->collection('bicycle_test_collection'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDatabaseReturnsMongoDatabase(): void
|
||||||
|
{
|
||||||
|
$config = Config::get('mongo', 'default');
|
||||||
|
$driver = new MongoDriver($config);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(\MongoDB\Database::class, $driver->database());
|
||||||
|
}
|
||||||
|
}
|
||||||
34
tests/Unit/MongoTest.php
Normal file
34
tests/Unit/MongoTest.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionClass;
|
||||||
|
use Services\Mongo;
|
||||||
|
use System\Classes\Config;
|
||||||
|
use System\Classes\MyException;
|
||||||
|
|
||||||
|
class MongoTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$ref = new ReflectionClass(Mongo::class);
|
||||||
|
$prop = $ref->getProperty('_instances');
|
||||||
|
$prop->setAccessible(true);
|
||||||
|
$prop->setValue(null, []);
|
||||||
|
|
||||||
|
$ref = new ReflectionClass(Config::class);
|
||||||
|
$prop = $ref->getProperty('config');
|
||||||
|
$prop->setAccessible(true);
|
||||||
|
$prop->setValue(null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownConnectionNameThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(MyException::class);
|
||||||
|
Mongo::instance('nonexistent');
|
||||||
|
}
|
||||||
|
|
||||||
|
// instance()/кэширование по имени с реальным MongoDriver — см. MongoDriverTest
|
||||||
|
// (там же markTestSkipped без ext-mongodb: сам MongoDriver::__construct() требует расширение).
|
||||||
|
}
|
||||||
133
tests/Unit/PdoDriverTest.php
Normal file
133
tests/Unit/PdoDriverTest.php
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Services\DataBase\Classes\PdoDriver;
|
||||||
|
use Services\DataBase\Classes\Profiler;
|
||||||
|
use System\Classes\Config;
|
||||||
|
use System\Classes\MyException;
|
||||||
|
|
||||||
|
class PdoDriverTest extends TestCase
|
||||||
|
{
|
||||||
|
private PdoDriver $driver;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$config = Config::get('db', 'default')['connection'] ?? [];
|
||||||
|
$this->driver = new PdoDriver($config);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->driver->pdo();
|
||||||
|
} catch (MyException $e) {
|
||||||
|
$this->markTestSkipped('Нет живого подключения к MariaDB (заполните App/config/config.local.php): ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->driver->exec('DROP TABLE IF EXISTS pdo_driver_test');
|
||||||
|
$this->driver->exec('CREATE TABLE pdo_driver_test (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255))');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
Profiler::reset();
|
||||||
|
|
||||||
|
if (isset($this->driver)) {
|
||||||
|
try {
|
||||||
|
$this->driver->exec('DROP TABLE IF EXISTS pdo_driver_test');
|
||||||
|
} catch (MyException) {
|
||||||
|
// соединения не было — нечего дропать
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFailoverThrowsWithAllHostErrors(): void
|
||||||
|
{
|
||||||
|
// Чисто логика failover — не требует живой БД, оба хоста заведомо недоступны
|
||||||
|
$driver = new PdoDriver(['hosts' => ['bad-host-1', 'bad-host-2'], 'dbname' => 'x', 'user' => 'x', 'password' => 'x']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$driver->pdo();
|
||||||
|
$this->fail('Ожидалось MyException');
|
||||||
|
} catch (MyException $e) {
|
||||||
|
$this->assertStringContainsString('bad-host-1', $e->getMessage());
|
||||||
|
$this->assertStringContainsString('bad-host-2', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testQueryInsertsAndSelects(): void
|
||||||
|
{
|
||||||
|
$this->driver->query('INSERT INTO pdo_driver_test (name) VALUES (?)', ['foo']);
|
||||||
|
$row = $this->driver->query('SELECT * FROM pdo_driver_test WHERE name = ?', ['foo'])->fetch();
|
||||||
|
|
||||||
|
$this->assertSame('foo', $row['name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLastInsertId(): void
|
||||||
|
{
|
||||||
|
$this->driver->query('INSERT INTO pdo_driver_test (name) VALUES (?)', ['foo']);
|
||||||
|
$this->assertNotFalse($this->driver->lastInsertId());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCommitPersistsChanges(): void
|
||||||
|
{
|
||||||
|
$this->driver->beginTransaction();
|
||||||
|
$this->driver->query('INSERT INTO pdo_driver_test (name) VALUES (?)', ['committed']);
|
||||||
|
$this->driver->commit();
|
||||||
|
|
||||||
|
$row = $this->driver->query('SELECT * FROM pdo_driver_test WHERE name = ?', ['committed'])->fetch();
|
||||||
|
$this->assertNotFalse($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRollbackDiscardsChanges(): void
|
||||||
|
{
|
||||||
|
$this->driver->beginTransaction();
|
||||||
|
$this->driver->query('INSERT INTO pdo_driver_test (name) VALUES (?)', ['rolled_back']);
|
||||||
|
$this->driver->rollback();
|
||||||
|
|
||||||
|
$row = $this->driver->query('SELECT * FROM pdo_driver_test WHERE name = ?', ['rolled_back'])->fetch();
|
||||||
|
$this->assertFalse($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNestedTransactionRollbackDoesNotAffectOuter(): void
|
||||||
|
{
|
||||||
|
$this->driver->beginTransaction();
|
||||||
|
$this->driver->query('INSERT INTO pdo_driver_test (name) VALUES (?)', ['outer']);
|
||||||
|
|
||||||
|
$this->driver->beginTransaction(); // SAVEPOINT sp_1
|
||||||
|
$this->driver->query('INSERT INTO pdo_driver_test (name) VALUES (?)', ['inner']);
|
||||||
|
$this->driver->rollback(); // ROLLBACK TO SAVEPOINT sp_1 — не трогает 'outer'
|
||||||
|
|
||||||
|
$this->driver->commit(); // фиксирует внешнюю транзакцию
|
||||||
|
|
||||||
|
$outer = $this->driver->query('SELECT * FROM pdo_driver_test WHERE name = ?', ['outer'])->fetch();
|
||||||
|
$inner = $this->driver->query('SELECT * FROM pdo_driver_test WHERE name = ?', ['inner'])->fetch();
|
||||||
|
|
||||||
|
$this->assertNotFalse($outer);
|
||||||
|
$this->assertFalse($inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTransactionHelperRollsBackOnException(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->driver->transaction(function (PdoDriver $driver) {
|
||||||
|
$driver->query('INSERT INTO pdo_driver_test (name) VALUES (?)', ['should_rollback']);
|
||||||
|
throw new \RuntimeException('boom');
|
||||||
|
});
|
||||||
|
$this->fail('Ожидалось RuntimeException');
|
||||||
|
} catch (\RuntimeException) {
|
||||||
|
// ожидаемо
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $this->driver->query('SELECT * FROM pdo_driver_test WHERE name = ?', ['should_rollback'])->fetch();
|
||||||
|
$this->assertFalse($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInTransaction(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse($this->driver->inTransaction());
|
||||||
|
$this->driver->beginTransaction();
|
||||||
|
$this->assertTrue($this->driver->inTransaction());
|
||||||
|
$this->driver->commit();
|
||||||
|
$this->assertFalse($this->driver->inTransaction());
|
||||||
|
}
|
||||||
|
}
|
||||||
60
tests/Unit/ProfilerTest.php
Normal file
60
tests/Unit/ProfilerTest.php
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Services\DataBase\Classes\Profiler;
|
||||||
|
use Services\DataBase\Classes\ProfilerPDO;
|
||||||
|
|
||||||
|
class ProfilerTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
// Другие тесты (PdoDriverTest/StatementTest/RepositoryTest) пишут в этот же
|
||||||
|
// статический реестр, если есть живое подключение к БД — сбрасываем перед своими тестами.
|
||||||
|
Profiler::reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
Profiler::reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogAddsEntry(): void
|
||||||
|
{
|
||||||
|
Profiler::log('SELECT 1', [], 1.5);
|
||||||
|
$entries = Profiler::entries();
|
||||||
|
|
||||||
|
$this->assertCount(1, $entries);
|
||||||
|
$this->assertSame('SELECT 1', $entries[0]['sql']);
|
||||||
|
$this->assertSame(1.5, $entries[0]['time_ms']);
|
||||||
|
$this->assertSame('pdo', $entries[0]['driver']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCountAndTotalTime(): void
|
||||||
|
{
|
||||||
|
Profiler::log('SELECT 1', [], 1.0);
|
||||||
|
Profiler::log('SELECT 2', [], 2.5);
|
||||||
|
|
||||||
|
$this->assertSame(2, Profiler::count());
|
||||||
|
$this->assertSame(3.5, Profiler::totalTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResetClearsEntries(): void
|
||||||
|
{
|
||||||
|
Profiler::log('SELECT 1', [], 1.0);
|
||||||
|
Profiler::reset();
|
||||||
|
|
||||||
|
$this->assertSame(0, Profiler::count());
|
||||||
|
$this->assertSame([], Profiler::entries());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testProfilerPdoWrapLogsAndReturnsResult(): void
|
||||||
|
{
|
||||||
|
$result = ProfilerPDO::wrap('SELECT 1', [], static fn () => 'result');
|
||||||
|
|
||||||
|
$this->assertSame('result', $result);
|
||||||
|
$this->assertCount(1, Profiler::entries());
|
||||||
|
$this->assertGreaterThanOrEqual(0, Profiler::entries()[0]['time_ms']);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
tests/Unit/ProfilerToolbarTest.php
Normal file
82
tests/Unit/ProfilerToolbarTest.php
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionClass;
|
||||||
|
use Services\Auth;
|
||||||
|
use Services\DataBase\Classes\Profiler;
|
||||||
|
use System\Classes\Config;
|
||||||
|
use System\Classes\ProfilerToolbar;
|
||||||
|
use System\Classes\Session;
|
||||||
|
|
||||||
|
class ProfilerToolbarTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $users_file;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->users_file = sys_get_temp_dir() . '/bicycle_toolbar_users_' . uniqid() . '.php';
|
||||||
|
file_put_contents($this->users_file, '<?php return ' . var_export([
|
||||||
|
'admin' => ['password' => password_hash('x', PASSWORD_DEFAULT), 'role' => 'admin'],
|
||||||
|
'bob' => ['password' => password_hash('x', PASSWORD_DEFAULT), 'role' => 'manager'],
|
||||||
|
], true) . ';');
|
||||||
|
|
||||||
|
Config::set('auth', ['driver' => 'file', 'users_file' => $this->users_file, 'session_key' => 'auth_user']);
|
||||||
|
|
||||||
|
// Другие тесты пишут в этот же статический реестр, если есть живое подключение к БД.
|
||||||
|
Profiler::reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
unlink($this->users_file);
|
||||||
|
Session::instance()->destroy();
|
||||||
|
Profiler::reset();
|
||||||
|
|
||||||
|
foreach ([Session::class, Auth::class] as $class) {
|
||||||
|
$ref = new ReflectionClass($class);
|
||||||
|
$prop = $ref->getProperty($class === Session::class ? 'instances' : '_instances');
|
||||||
|
$prop->setAccessible(true);
|
||||||
|
$prop->setValue(null, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ref = new ReflectionClass(Config::class);
|
||||||
|
$prop = $ref->getProperty('config');
|
||||||
|
$prop->setAccessible(true);
|
||||||
|
$prop->setValue(null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsEmptyStringForGuest(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('', ProfilerToolbar::render());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsEmptyStringForNonAdminRole(): void
|
||||||
|
{
|
||||||
|
Session::instance()->set('auth_user', 'bob');
|
||||||
|
|
||||||
|
$this->assertSame('', ProfilerToolbar::render());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRendersPanelForAdmin(): void
|
||||||
|
{
|
||||||
|
Session::instance()->set('auth_user', 'admin');
|
||||||
|
|
||||||
|
$html = ProfilerToolbar::render();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('Profiler', $html);
|
||||||
|
$this->assertStringContainsString('SQL', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIncludesProfilerEntries(): void
|
||||||
|
{
|
||||||
|
Session::instance()->set('auth_user', 'admin');
|
||||||
|
Profiler::log('SELECT * FROM users WHERE id = ?', [42], 1.23);
|
||||||
|
|
||||||
|
$html = ProfilerToolbar::render();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('SELECT * FROM users WHERE id = ?', $html);
|
||||||
|
$this->assertStringContainsString('42', $html);
|
||||||
|
}
|
||||||
|
}
|
||||||
157
tests/Unit/RepositoryTest.php
Normal file
157
tests/Unit/RepositoryTest.php
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionClass;
|
||||||
|
use Services\Database;
|
||||||
|
use Services\DataBase\Classes\PdoDriver;
|
||||||
|
use Services\DataBase\Classes\Profiler;
|
||||||
|
use System\Classes\Config;
|
||||||
|
use System\Classes\MyException;
|
||||||
|
use System\Classes\Repository;
|
||||||
|
|
||||||
|
class RepositoryTestItemRepository extends Repository
|
||||||
|
{
|
||||||
|
public function __construct(?string $connection = null)
|
||||||
|
{
|
||||||
|
parent::__construct('repository_test_items', connection: $connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RepositoryTest extends TestCase
|
||||||
|
{
|
||||||
|
private PdoDriver $driver;
|
||||||
|
private RepositoryTestItemRepository $repository;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$config = Config::get('db', 'default')['connection'] ?? [];
|
||||||
|
$this->driver = new PdoDriver($config);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->driver->pdo();
|
||||||
|
} catch (MyException $e) {
|
||||||
|
$this->markTestSkipped('Нет живого подключения к MariaDB (заполните App/config/config.local.php): ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->driver->exec('DROP TABLE IF EXISTS repository_test_items');
|
||||||
|
$this->driver->exec('CREATE TABLE repository_test_items (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255), qty INT)');
|
||||||
|
|
||||||
|
$this->repository = new RepositoryTestItemRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
Profiler::reset();
|
||||||
|
|
||||||
|
if (isset($this->driver)) {
|
||||||
|
try {
|
||||||
|
$this->driver->exec('DROP TABLE IF EXISTS repository_test_items');
|
||||||
|
} catch (MyException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database::instance() кэширует подключение статически — сбрасываем между тестами
|
||||||
|
$ref = new ReflectionClass(Database::class);
|
||||||
|
$prop = $ref->getProperty('_instances');
|
||||||
|
$prop->setAccessible(true);
|
||||||
|
$prop->setValue(null, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateAndGet(): void
|
||||||
|
{
|
||||||
|
$id = $this->repository->create(['name' => 'foo', 'qty' => 3]);
|
||||||
|
$row = $this->repository->get($id);
|
||||||
|
|
||||||
|
$this->assertSame('foo', $row->name);
|
||||||
|
$this->assertSame(3, (int) $row->qty);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetMissingReturnsFalse(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse($this->repository->get(999999));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetListWithWhere(): void
|
||||||
|
{
|
||||||
|
$this->repository->create(['name' => 'a', 'qty' => 1]);
|
||||||
|
$this->repository->create(['name' => 'b', 'qty' => 2]);
|
||||||
|
$this->repository->create(['name' => 'a', 'qty' => 5]);
|
||||||
|
|
||||||
|
$rows = $this->repository->getList('*', ['name' => 'a']);
|
||||||
|
|
||||||
|
$this->assertCount(2, $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetItemWhere(): void
|
||||||
|
{
|
||||||
|
$this->repository->create(['name' => 'foo', 'qty' => 1]);
|
||||||
|
$row = $this->repository->getItemWhere("name = 'foo'");
|
||||||
|
|
||||||
|
$this->assertSame('foo', $row->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetItemWhereEmptyReturnsNull(): void
|
||||||
|
{
|
||||||
|
$this->assertNull($this->repository->getItemWhere(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdate(): void
|
||||||
|
{
|
||||||
|
$id = $this->repository->create(['name' => 'foo', 'qty' => 1]);
|
||||||
|
$affected = $this->repository->update(['id' => $id, 'qty' => 10]);
|
||||||
|
|
||||||
|
$this->assertSame(1, $affected);
|
||||||
|
$this->assertSame(10, (int) $this->repository->get($id)->qty);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateWithoutPrimaryKeyThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(MyException::class);
|
||||||
|
$this->repository->update(['qty' => 10]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDelete(): void
|
||||||
|
{
|
||||||
|
$id = $this->repository->create(['name' => 'foo', 'qty' => 1]);
|
||||||
|
|
||||||
|
$this->assertTrue($this->repository->delete($id));
|
||||||
|
$this->assertFalse($this->repository->get($id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeleteWhere(): void
|
||||||
|
{
|
||||||
|
$this->repository->create(['name' => 'a', 'qty' => 1]);
|
||||||
|
$this->repository->create(['name' => 'a', 'qty' => 2]);
|
||||||
|
$this->repository->create(['name' => 'b', 'qty' => 3]);
|
||||||
|
|
||||||
|
$this->repository->deleteWhere('name = ?', ['a']);
|
||||||
|
|
||||||
|
$this->assertCount(1, $this->repository->getList('*'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTransactionRollsBackOnException(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->repository->transaction(function () {
|
||||||
|
$this->repository->create(['name' => 'should_rollback', 'qty' => 1]);
|
||||||
|
throw new \RuntimeException('boom');
|
||||||
|
});
|
||||||
|
$this->fail('Ожидалось RuntimeException');
|
||||||
|
} catch (\RuntimeException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertCount(0, $this->repository->getList('*', ['name' => 'should_rollback']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetListFetchAssoc(): void
|
||||||
|
{
|
||||||
|
$this->repository->create(['name' => 'foo', 'qty' => 1]);
|
||||||
|
$rows = $this->repository->getList('*', [], '', 'ASC', PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$this->assertIsArray($rows[0]);
|
||||||
|
$this->assertSame('foo', $rows[0]['name']);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
tests/Unit/StatementTest.php
Normal file
52
tests/Unit/StatementTest.php
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Services\DataBase\Classes\PdoDriver;
|
||||||
|
use Services\DataBase\Classes\Profiler;
|
||||||
|
use System\Classes\Config;
|
||||||
|
use System\Classes\MyException;
|
||||||
|
|
||||||
|
class StatementTest extends TestCase
|
||||||
|
{
|
||||||
|
private PdoDriver $driver;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$config = Config::get('db', 'default')['connection'] ?? [];
|
||||||
|
$this->driver = new PdoDriver($config);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->driver->pdo();
|
||||||
|
} catch (MyException $e) {
|
||||||
|
$this->markTestSkipped('Нет живого подключения к MariaDB (заполните App/config/config.local.php): ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
Profiler::reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testShowQuerySubstitutesPositionalParams(): void
|
||||||
|
{
|
||||||
|
$statement = $this->driver->query('SELECT ? AS a, ? AS b', ['foo', 42]);
|
||||||
|
|
||||||
|
$this->assertSame("SELECT 'foo' AS a, 42 AS b", $statement->showQuery());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testShowQuerySubstitutesNamedParams(): void
|
||||||
|
{
|
||||||
|
$statement = $this->driver->query('SELECT :name AS name', ['name' => 'bicycle']);
|
||||||
|
|
||||||
|
$this->assertSame("SELECT 'bicycle' AS name", $statement->showQuery());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSqIsAliasForShowQuery(): void
|
||||||
|
{
|
||||||
|
$statement = $this->driver->query('SELECT ? AS a', ['foo']);
|
||||||
|
|
||||||
|
$this->assertSame($statement->showQuery(), $statement->sq());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,6 +20,14 @@ class ViewTest extends TestCase
|
|||||||
if (file_exists($this->tmpFile)) {
|
if (file_exists($this->tmpFile)) {
|
||||||
unlink($this->tmpFile);
|
unlink($this->tmpFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сбрасываем статические реестры CSS/JS/manifest между тестами
|
||||||
|
$ref = new \ReflectionClass(View::class);
|
||||||
|
foreach (['_styles' => [], '_scripts' => [], '_manifest' => null] as $prop => $default) {
|
||||||
|
$property = $ref->getProperty($prop);
|
||||||
|
$property->setAccessible(true);
|
||||||
|
$property->setValue(null, $default);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRenderStaticContent(): void
|
public function testRenderStaticContent(): void
|
||||||
@ -117,4 +125,100 @@ class ViewTest extends TestCase
|
|||||||
$result = $view->setFilename('index', 'view/Index');
|
$result = $view->setFilename('index', 'view/Index');
|
||||||
$this->assertSame($view, $result);
|
$this->assertSame($view, $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testSetStyleRegistersLinkTag(): void
|
||||||
|
{
|
||||||
|
(new View())->setStyle('app.css');
|
||||||
|
ob_start();
|
||||||
|
(new View())->getStyles();
|
||||||
|
$html = ob_get_clean();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('<link rel="stylesheet" href="/App/media/css/app.css">', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetStyleMissingFileEchoesAlert(): void
|
||||||
|
{
|
||||||
|
ob_start();
|
||||||
|
(new View())->setStyle('nonexistent_xyz.css');
|
||||||
|
$html = ob_get_clean();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('alert-danger', $html);
|
||||||
|
$this->assertStringContainsString('nonexistent_xyz.css', $html);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
(new View())->getStyles();
|
||||||
|
$this->assertSame('', ob_get_clean());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetScriptRegistersScriptTag(): void
|
||||||
|
{
|
||||||
|
(new View())->setScript('app.js');
|
||||||
|
ob_start();
|
||||||
|
(new View())->getScripts();
|
||||||
|
$html = ob_get_clean();
|
||||||
|
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'<script type="text/javascript" src="/App/media/js/app.js"></script>', $html
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetScriptCustomType(): void
|
||||||
|
{
|
||||||
|
(new View())->setScript('app.js', 'module');
|
||||||
|
ob_start();
|
||||||
|
(new View())->getScripts();
|
||||||
|
$html = ob_get_clean();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('type="module"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetScriptMissingFileEchoesAlert(): void
|
||||||
|
{
|
||||||
|
ob_start();
|
||||||
|
(new View())->setScript('nonexistent_xyz.js');
|
||||||
|
$html = ob_get_clean();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('alert-danger', $html);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
(new View())->getScripts();
|
||||||
|
$this->assertSame('', ob_get_clean());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetManifestRegistersLinkTag(): void
|
||||||
|
{
|
||||||
|
(new View())->setManifest('manifest.json');
|
||||||
|
ob_start();
|
||||||
|
(new View())->getStyles();
|
||||||
|
$html = ob_get_clean();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('<link rel="manifest" href="/App/media/manifest.json">', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetManifestMissingFileEchoesAlert(): void
|
||||||
|
{
|
||||||
|
ob_start();
|
||||||
|
(new View())->setManifest('nonexistent_xyz.json');
|
||||||
|
$html = ob_get_clean();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('alert-danger', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetStylesAndScriptsAreSharedAcrossViewInstances(): void
|
||||||
|
{
|
||||||
|
// content и layout — разные объекты View, но реестр общий (см. Controller::render())
|
||||||
|
(new View())->setStyle('app.css');
|
||||||
|
(new View())->setScript('app.js');
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
(new View())->getStyles();
|
||||||
|
$styles = ob_get_clean();
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
(new View())->getScripts();
|
||||||
|
$scripts = ob_get_clean();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('app.css', $styles);
|
||||||
|
$this->assertStringContainsString('app.js', $scripts);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user