This commit is contained in:
Egor Isaev 2026-08-07 11:40:03 +03:00
parent af0c247403
commit 9d4edf147a
47 changed files with 3090 additions and 145 deletions

View File

@ -7,8 +7,9 @@
- [Не коммитить самому](feedback_no_commit.md) — git commit/push делает пользователь вручную
- [Общение по-русски](feedback_russian_communication.md) — включая описания Bash-вызовов и подобных tool-call'ов
- [Меньше уточнений](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
- [Учебный проект](feedback_educational_project.md) — не предлагать замену на Laravel/Symfony
- [Место хранения памяти](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 для миграций/крона

View 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]]:
там речь про паузы-подтверждения на уже согласованных, обратимых шагах; здесь — про то, чтобы вообще
не расширять объём работы (лишние методы, лишний рефакторинг, лишние файлы) сверх того, что запросили.
Если вопрос пользователя похож на «а мы это ещё где-то используем/будем использовать?» — это, как
правило, не команда действовать, а проверка моей логики; сначала ответить прямо, не хвататься за
редактирование кода.

View File

@ -1,20 +1,47 @@
---
name: roadmap
description: Что ещё не реализовано в Bicycle — слой БД и связанные классы
description: Статус слоя БД (MariaDB/Mongo/Elasticsearch) в Bicycle — что реализовано, что ждёт внешней инфраструктуры
metadata:
type: project
---
Запланировано, но пока не реализовано (по состоянию на 2026-08-06):
Реализовано (2026-08-07) — три независимых сервиса, каждый со своей фабрикой в духе `Services\Auth`,
без общего интерфейса поверх всех трёх (SQL/документы/поиск — слишком разные модели, чтобы прятать
за одной абстракцией). Подробности использования — CLAUDE.md → разделы DataBase/Elasticsearch/Mongo.
- `Model` — лёгкий base-класс с `__get`/`__set`, `toArray()`.
- `Database` + `PdoDriver` — PDO singleton с failover.
- `Statement` — расширение `PDOStatement` (`showQuery()`/`sq()`).
- `Repository` — abstract CRUD с транзакциями/SAVEPOINT.
- `Profiler` + `ProfilerPDO` — тайминги SQL-запросов.
- **Реляционная БД (MariaDB, полностью рабочая и протестированная)** — `Services\Database` (фабрика),
`Services\DataBase\Classes\PdoDriver` (failover по хостам, транзакции с вложенными SAVEPOINT),
`Services\DataBase\Classes\Statement` (`showQuery()`/`sq()`), `Services\DataBase\Classes\Profiler`/`ProfilerPDO`
(тайминги), `Services\DataBase\Model` (лёгкий носитель атрибутов). `System\Classes\Repository` — abstract
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-точка входа отложена именно до появления этого слоя (первый реальный потребитель — миграции;
теперь, когда слой БД есть, эта идея стала актуальнее).

View 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');
}
}

View File

@ -13,6 +13,8 @@ use System\Classes\Controller;
/**
* Базовый контроллер админки. Рендерится в общем layout сайта
* (App/view/layout.html), своего отдельного шаблона не имеет.
* Боковое меню — общее для всего сайта, см. Controller::menuSide()
* (для роли admin оно показывается на любой странице, не только в админке).
* Требует авторизацию с ролью admin (см. Controller::$_auth_protection/$_auth_roles) —
* неавторизованный редиректится на /login, авторизованный без роли admin получает 403.
* Конкретные страницы (Logs и т.п.) наследуют его.

View File

@ -8,6 +8,7 @@
namespace App\Controller;
use App\Repositories\BudgetRepository;
use System\Classes\Controller;
use System\Classes\HTTP\Client\Curl;
use System\Classes\HTTP\Client\Request as HttpRequest;
@ -25,7 +26,9 @@ class IndexController extends Controller
*/
public function indexAction(): string
{
$todo = [];
$budgetR = new BudgetRepository();
var_dump($budgetR->getLists());die;
return $this->render('index', ['todo' => $todo]);
}

View 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]);
}
}

View 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);
}
}

View File

@ -10,14 +10,30 @@ return [
'default' => [
'driver' => 'pdo',
'connection' => [
'host' => 'db',
'dbname' => 'bicycle',
'host' => '192.168.1.104',
'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' => [
'cookie_params' => [
'lifetime' => 0,

1
App/media/css/app.css Normal file
View File

@ -0,0 +1 @@
/* Bicycle — собственные стили сайта поверх Bootstrap */

1
App/media/js/app.js Normal file
View File

@ -0,0 +1 @@
// Bicycle — собственный JS сайта

6
App/media/manifest.json Normal file
View File

@ -0,0 +1,6 @@
{
"name": "Bicycle",
"short_name": "Bicycle",
"start_url": "/",
"display": "standalone"
}

13
App/view/About/about.html Normal file
View 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>

View File

@ -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
View 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; ?>

View File

@ -1,10 +1,12 @@
<?php
/**
* @package Bicycle
* @description layout.html — главный шаблон сайта с верхним меню
* @description layout.html — главный шаблон сайта; верхнее и боковое меню
* приходят уже отрендеренными (см. Controller::renderMenuTop/renderMenuSide)
*
* @var string $content Содержимое страницы
* @var array $menu Пункты меню [['title','url','active'], ...]
* @var string $menu_top HTML верхнего меню
* @var string $menu_side HTML бокового меню; пустая строка — колонка не рисуется
*/
?>
<!DOCTYPE html>
@ -15,27 +17,25 @@
<title>Bicycle</title>
<link rel="stylesheet" href="/vendor/twbs/bootstrap/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="/vendor/twbs/bootstrap-icons/font/bootstrap-icons.css">
<?php $this->getStyles(); ?>
</head>
<body>
<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>
<?= $menu_top ?>
<?php if ($menu_side !== ''): ?>
<div class="container py-3">
<div class="row">
<aside class="col-md-3 col-lg-2 mb-3"><?= $menu_side ?></aside>
<main class="col-md-9 col-lg-10"><?= $content ?></main>
</div>
</nav>
<main class="container py-3">
</div>
<?php else: ?>
<main class="container py-3">
<?= $content ?>
</main>
</main>
<?php endif; ?>
<script src="/vendor/components/jquery/jquery.min.js"></script>
<script src="/vendor/twbs/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<?php $this->getScripts(); ?>
<?= \System\Classes\ProfilerToolbar::render() ?>
</body>
</html>

16
App/view/menu_side.html Normal file
View 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
View 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
View File

@ -38,17 +38,21 @@ System/Classes/HTTP/ — HTTP-инфраструктура: инт
System/Classes/HTTP/Client/ — исходящие HTTP-запросы (Curl, Request, Response)
System/Classes/HTTP/Exception/ — HTTPException_302/403/404
App/Controller/ — контроллеры приложения (namespace App\Controller\)
App/Repositories/ — конкретные репозитории (extends System\Classes\Repository), например BudgetRepository
App/config/ — конфиги (config.php; config.local.php — в .gitignore)
App/view/ — шаблоны приложения (.html файлы с PHP-кодом)
App/media/ — статические ресурсы (js, css, img)
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)
tools/sort_html_attrs.php — CLI-утилита сортировки HTML-атрибутов (gitignored: /tools/* в .gitignore)
tests/ — PHPUnit тесты
```
> Каталог `App/Classes/` и часть `Services/*` (DataBase, Mail, PDF) ещё не созданы — namespace-конвенции ниже описывают, *куда* класть код, когда он появится, а не существующие файлы.
> Каталог `App/Classes/` и часть `Services/*` (Mail, PDF) ещё не созданы — namespace-конвенции ниже описывают, *куда* класть код, когда он появится, а не существующие файлы.
### Autoload (composer.json)
@ -76,8 +80,9 @@ PhpStorm может показывать предупреждение «Namespac
|---|---|
| `System\Classes\Core` | Bootstrap, обработка ошибок, поиск файлов (`findFile`), константы среды |
| `System\Classes\BaseController` | Голое ядро (abstract); жизненный цикл `executeAction()`: `before()` → экшен → `after()` |
| `System\Classes\Controller` | Веб-контроллер (extends BaseController): `render()` + авто-CSRF в `before()` + `json()` |
| `System\Classes\View` | Рендеринг `.html`-шаблонов через `ob_start` + `extract` + `include` |
| `System\Classes\Controller` | Веб-контроллер (extends BaseController): `render()`/`renderContent()` + авто-CSRF в `before()` + `json()` |
| `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\Request` | Входящий HTTP-запрос; реализует `HTTP\Request`; `post()`, `query()`, `body()`, `isAjax()` |
| `System\Classes\Response` | HTTP-ответ; `body()` — геттер/сеттер содержимого |
@ -104,6 +109,17 @@ PhpStorm может показывать предупреждение «Namespac
| `Services\Auth` | Точка входа в авторизацию: `instance(?string $driver = null)`, кэш по драйверу |
| `Services\Auth\AuthDriver` | Интерфейс: `login()`, `logout()`, `loggedIn()`, `getUser()`, `checkPassword()` |
| `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
@ -293,6 +309,128 @@ return [
«Войти» либо «Выйти (логин)» в зависимости от `Auth::instance()->getUser()`; пункт «Админка» виден
только при `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)
Файловый логгер в стиле `Config`/`Cookie`. Пишет в два файла, именованных датой:
@ -363,16 +501,38 @@ $response = HTTPException::factory(403)->getResponse();
- `$_paths` инициализируется лениво при первом вызове `findFile()`
- `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
Расстановка имён — как в проекте eoffice_v3:
- `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).
**Админка** (`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()` вручную.
@ -400,7 +560,7 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap
| `tests/Unit/MyExceptionTest.php` | `strtr` замена, code, previous |
| `tests/Unit/CoreTest.php` | `findFile()`, `getConst()`, environment |
| `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/CookieTest.php` | `get()`, `set()`, `delete()`, defaults |
| `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/FileAuthDriverTest.php` | `login()` верно/неверно/неизвестный логин, `loggedIn()`, `getUser()` без пароля, `logout()`, `checkPassword()`, отсутствие файла |
| `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)

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

View 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 = [];
}
}

View 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;
}
}

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

View 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
View 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];
}
}

View 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];
}
}

View 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
View 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];
}
}

View 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);
}
}

View File

@ -39,7 +39,7 @@ class Controller extends BaseController
* 403 — при недостаточной роли).
*
* @return void
* @throws HTTPException 403, если CSRF-токен не прошёл или роль не подходит
* @throws HTTPException|MyException 403, если CSRF-токен не прошёл или роль не подходит
*/
protected function before(): void
{
@ -70,9 +70,7 @@ class Controller extends BaseController
}
/**
* Рендерит шаблон контента внутри layout.
* Если $dir пуст — определяется автоматически из имени класса
* (App\Controller\FooController → view/Foo).
* Рендерит шаблон контента внутри layout вместе с верхним и боковым меню.
*
* @param string $template Имя шаблона контента без расширения
* @param array $data Данные, передаваемые в шаблон
@ -81,35 +79,68 @@ class Controller extends BaseController
* @throws MyException
*/
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 === '') {
$class = substr(get_class($this), strlen('App\\Controller\\')); // [Admin\]FooController
$dir = 'view/' . str_replace('\\', '/', substr($class, 0, -10)); // view/[Admin/]Foo
}
return (new View($this->_layout, 'view', [
'content' => (new View($template, $dir, $data))->render(),
] + $this->layoutData()))->render();
return (new View($template, $dir, $data))->render();
}
/**
* Дополнительные данные, передаваемые в layout помимо 'content'.
* По умолчанию — пункты главного меню; наследники могут переопределить
* полностью (как AdminController — своим боковым меню).
* HTML верхнего меню сайта — рендерит {@see menuTop()} через шаблон view/menu_top.
*
* @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
*/
protected function menu(): array
protected function menuTop(): array
{
$uri = Request::$current?->uri() ?? '';
$user = Auth::instance()->getUser();
@ -133,12 +164,36 @@ class Controller extends BaseController
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, кодирует данные.
*
* @param mixed $data Данные ответа
* @param int $status HTTP-статус
* @return string JSON
* @throws \JsonException
*/
protected function json(mixed $data, int $status = 200): string
{

View 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;
}
}

View 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;
}
}

View File

@ -25,6 +25,22 @@ class View
*/
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 Имя файла шаблона без расширения
@ -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";
}
}
}

View File

@ -2,6 +2,7 @@
"require": {
"php": ">=8.2",
"ext-pdo": "*",
"ext-pdo_mysql": "*",
"phpoffice/phpspreadsheet": "5.*",
"phpoffice/phpword": "1.1.0",
"phpmailer/phpmailer": "v6.9.*",
@ -10,7 +11,8 @@
"select2/select2": "4.1.*",
"components/jquery": "v3.7.1",
"components/jqueryui": "1.12.*",
"dompdf/dompdf": "v3.1.*"
"dompdf/dompdf": "v3.1.*",
"mongodb/mongodb": "^2.3"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
@ -31,6 +33,9 @@
"allow-plugins": {
"php-http/discovery": true,
"composer/installers": true
},
"platform": {
"ext-mongodb": "2.3"
}
}
}

351
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "d15667ff3f0882b8e8daa05eae634821",
"content-hash": "d9e5b024bdd073ae913e3de7dc02e71e",
"packages": [
{
"name": "components/jquery",
@ -146,28 +146,29 @@
},
{
"name": "composer/pcre",
"version": "3.3.2",
"version": "3.4.0",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
"phpstan/phpstan": "<2.2.2"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
"phpstan/phpstan": "^2",
"phpstan/phpstan-deprecation-rules": "^2",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "^9"
},
"type": "library",
"extra": {
@ -205,7 +206,7 @@
],
"support": {
"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": [
{
@ -215,26 +216,22 @@
{
"url": "https://github.com/composer",
"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",
"version": "v3.1.5",
"version": "v3.1.6",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496"
"reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/6d4b4eb8500f7a786da8868ba463a71b725a4005",
"reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005",
"shasum": ""
},
"require": {
@ -283,9 +280,9 @@
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"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",
@ -626,16 +623,16 @@
},
{
"name": "masterminds/html5",
"version": "2.10.0",
"version": "2.10.1",
"source": {
"type": "git",
"url": "https://github.com/Masterminds/html5-php.git",
"reference": "fcf91eb64359852f00d921887b219479b4f21251"
"reference": "fd5018f6815fff903946d0564977b44ce8010e29"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
"reference": "fcf91eb64359852f00d921887b219479b4f21251",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29",
"reference": "fd5018f6815fff903946d0564977b44ce8010e29",
"shasum": ""
},
"require": {
@ -643,7 +640,7 @@
"php": ">=5.3.0"
},
"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",
"extra": {
@ -687,9 +684,82 @@
],
"support": {
"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",
@ -774,16 +844,16 @@
},
{
"name": "phpoffice/phpspreadsheet",
"version": "5.7.0",
"version": "5.9.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8"
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
"reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/05e99ebf61238a70227b4d9cc02d0030d34f6339",
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339",
"shasum": ""
},
"require": {
@ -805,7 +875,7 @@
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": "^8.1",
"php": "^8.2",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
@ -819,7 +889,7 @@
"phpstan/phpstan": "^1.1 || ^2.0",
"phpstan/phpstan-deprecation-rules": "^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",
"tecnickcom/tcpdf": "^6.5"
},
@ -877,9 +947,9 @@
],
"support": {
"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",
@ -989,6 +1059,56 @@
},
"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",
"version": "3.0.0",
@ -1042,16 +1162,16 @@
},
{
"name": "sabberworm/php-css-parser",
"version": "v9.3.0",
"version": "v9.4.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
"reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949"
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949",
"reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949",
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
"shasum": ""
},
"require": {
@ -1062,15 +1182,15 @@
"require-dev": {
"php-parallel-lint/php-parallel-lint": "1.4.0",
"phpstan/extension-installer": "1.4.3",
"phpstan/phpstan": "1.12.32 || 2.1.32",
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.8",
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7",
"phpstan/phpstan": "1.12.33 || 2.2.2",
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.16",
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11",
"phpunit/phpunit": "8.5.52",
"rawr/phpunit-data-provider": "3.3.1",
"rector/rector": "1.2.10 || 2.2.8",
"rector/type-perfect": "1.0.0 || 2.1.0",
"rector/rector": "1.2.10 || 2.4.6",
"rector/type-perfect": "1.0.0 || 2.1.3",
"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": {
"ext-mbstring": "for parsing UTF-8 CSS"
@ -1078,7 +1198,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "9.4.x-dev"
"dev-main": "9.5.x-dev"
}
},
"autoload": {
@ -1116,9 +1236,9 @@
],
"support": {
"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",
@ -1172,6 +1292,86 @@
],
"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",
"version": "v3.4.0",
@ -1468,20 +1668,19 @@
},
{
"name": "nikic/php-parser",
"version": "v5.7.0",
"version": "v5.8.0",
"source": {
"type": "git",
"url": "https://github.com/nikic/PHP-Parser.git",
"reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
"reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
"reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
"reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-json": "*",
"ext-tokenizer": "*",
"php": ">=7.4"
@ -1520,9 +1719,9 @@
],
"support": {
"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",
@ -1991,24 +2190,24 @@
},
{
"name": "phpunit/phpunit",
"version": "11.5.55",
"version": "11.5.56",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00"
"reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00",
"reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5f83edffa6967c3db468d48a695ec7bcb02e9256",
"reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-filter": "*",
"ext-json": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-xml": "*",
"ext-xmlwriter": "*",
"myclabs/deep-copy": "^1.13.4",
"phar-io/manifest": "^2.0.4",
@ -2073,31 +2272,15 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"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": [
{
"url": "https://phpunit.de/sponsors.html",
"type": "custom"
},
{
"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"
"url": "https://phpunit.de/sponsoring.html",
"type": "other"
}
],
"time": "2026-02-18T12:37:06+00:00"
"time": "2026-07-06T14:52:39+00:00"
},
{
"name": "sebastian/cli-parser",
@ -3195,8 +3378,12 @@
"prefer-lowest": false,
"platform": {
"php": ">=8.2",
"ext-pdo": "*"
"ext-pdo": "*",
"ext-pdo_mysql": "*"
},
"platform-dev": {},
"platform-overrides": {
"ext-mongodb": "2.3"
},
"plugin-api-version": "2.9.0"
}

View 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');
}
}

View 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'));
}
}

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

View 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
View 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() требует расширение).
}

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

View 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']);
}
}

View 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);
}
}

View 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']);
}
}

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

View File

@ -20,6 +20,14 @@ class ViewTest extends TestCase
if (file_exists($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
@ -117,4 +125,100 @@ class ViewTest extends TestCase
$result = $view->setFilename('index', 'view/Index');
$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);
}
}