This commit is contained in:
Egor Isaev 2026-08-07 12:34:27 +03:00
parent 9d4edf147a
commit 9ff9cc54e6
16 changed files with 553 additions and 134 deletions

View File

@ -11,5 +11,6 @@
- [Auth API — терминология Kohana](feedback_auth_api_naming.md) — login/logout/loggedIn/getUser/checkPassword, Auth::instance(), без слова Provider - [Auth API — терминология Kohana](feedback_auth_api_naming.md) — login/logout/loggedIn/getUser/checkPassword, Auth::instance(), без слова Provider
- [Учебный проект](feedback_educational_project.md) — не предлагать замену на Laravel/Symfony - [Учебный проект](feedback_educational_project.md) — не предлагать замену на Laravel/Symfony
- [Место хранения памяти](feedback_memory_location.md) — вся память по Bicycle тут, не в глобальном auto-memory - [Место хранения памяти](feedback_memory_location.md) — вся память по Bicycle тут, не в глобальном auto-memory
- [Roadmap: слой БД](roadmap.md) — MariaDB/PDO реализован и протестирован; Mongo/Elasticsearch написаны, ждут внешней инфры от пользователя - [Roadmap: слой БД](roadmap.md) — MariaDB/PDO реализован и протестирован (реальное подключение); Mongo/Elasticsearch написаны, ждут внешней инфры от пользователя
- [Работа с референс-кодом из других проектов](feedback_reference_code_handling.md) — пользователь кидает код из eoffice_v3 и т.п.: адаптировать критически, не копировать вслепую, но уважать явные повторные сигналы по неймингу
- [Идея: CLI-точка входа](idea_cli_entrypoint.md) — отложенный cli.php для миграций/крона - [Идея: CLI-точка входа](idea_cli_entrypoint.md) — отложенный cli.php для миграций/крона

View File

@ -0,0 +1,36 @@
---
name: feedback-reference-code-handling
description: Пользователь часто присылает код из своих других проектов (eoffice_v3 и т.п.) как референс — как с этим работать
metadata:
type: project
---
Пользователь регулярно кидает готовый код из своих старых/других проектов (`/home/isaevea/http/eoffice_v3`
и подобные) как образец того, что нужно сделать в Bicycle — целыми классами (Repository, Model,
render()-методы Controller, CSS/JS-подключение во View). Это не абстрактные пожелания, а буквальный
рабочий код, который он хочет видеть похожим здесь.
**Why:** Несколько раз за сессию 2026-08-07 я либо: (а) придумывал что-то стилистически похожее, но не
совпадающее с тем, что он реально писал в своём коде (например свойство `$_driver` вместо `$pdo` —
он дважды написал `$this->pdo` сам, прежде чем я понял, что нужно называть именно так), либо
(б) слепо копировал референс, не заметив реальные баги/нестыковки в нём (в `eoffice_v3`
`Repository::getList()` по умолчанию `FETCH_KEY_PAIR` — падает не на ровно 2 колонках; `FETCH_CLASS`
не получает `obj_class`; свой счётчик вложенности транзакций в каждом Repository — ломается, если два
репозитория делят одно PDO-подключение). На прямой вопрос «у нас свой проект, мне нужно не также а
правильно» — стало ясно: копировать вслепую не нужно, нужно оценивать критически.
**How to apply:**
1. Когда пользователь присылает референс-код — сначала спросить себя (не обязательно вслух), реально
ли это то, что нужно 1-в-1, или в нём могут быть баги/устаревшие паттерны конкретно под старый проект
(например поддержка нескольких "модулей", которых в Bicycle нет).
2. Портировать с адаптацией под реалии Bicycle (уже собранные классы вроде `PdoDriver` с его SAVEPOINT-
логикой могут делать что-то лучше/надёжнее, чем то, что в референсе — не дублировать хуже).
3. Если сомневаешься, действительно ли имя/сигнатура должны совпадать буквально — можно посмотреть
исходник самого референс-проекта на диске (он часто лежит рядом, в других папках `/home/isaevea/http/*`),
а не гадать по вставленным фрагментам — так нашлись оригиналы `Database.php`/`PdoDriver.php`/
`Statement.php`/`ProfilerPDO.php`/`Repository.php` в `eoffice_v3/System/Classes/`.
4. Если пользователь явно пишет что-то вроде `$this->pdo` (даже с ошибкой в другом месте кода) —
это сильный сигнал именно про нейминг, не опечатка; после второго повтора — не переспрашивать,
а сразу переименовывать под него (см. также [[feedback-less-confirmation]]).
5. После адаптации — явно резюмировать пользователю, ЧТО именно отличается от референса и почему
(иначе выглядит как будто я не выполнил задачу «сделай как там»), см. пример в `roadmap.md`.

View File

@ -9,12 +9,31 @@ metadata:
без общего интерфейса поверх всех трёх (SQL/документы/поиск — слишком разные модели, чтобы прятать без общего интерфейса поверх всех трёх (SQL/документы/поиск — слишком разные модели, чтобы прятать
за одной абстракцией). Подробности использования — CLAUDE.md → разделы DataBase/Elasticsearch/Mongo. за одной абстракцией). Подробности использования — CLAUDE.md → разделы DataBase/Elasticsearch/Mongo.
- **Реляционная БД (MariaDB, полностью рабочая и протестированная)** — `Services\Database` (фабрика), - **Реляционная БД (MariaDB, полностью рабочая и протестированная, реальное подключение)** —
`Services\DataBase\Classes\PdoDriver` (failover по хостам, транзакции с вложенными SAVEPOINT), `Services\Database` (фабрика), `Services\DataBase\Classes\PdoDriver` (failover по хостам, транзакции
`Services\DataBase\Classes\Statement` (`showQuery()`/`sq()`), `Services\DataBase\Classes\Profiler`/`ProfilerPDO` с вложенными SAVEPOINT — единственный источник истины по вложенности, общий на всё подключение),
(тайминги), `Services\DataBase\Model` (лёгкий носитель атрибутов). `System\Classes\Repository` — abstract `Services\DataBase\Classes\Statement` (`showQuery()`/`sq()`, `execute()` профилирует в `Profiler`), `Services\DataBase\Classes\Profiler`
CRUD; конкретные репозитории — в `App/Repositories/*` (например `App\Repositories\BudgetRepository`), (тайминги, см. также `System\Classes\ProfilerToolbar`).
не в `Services\DataBase` — так решил пользователь явно. - **`System\Classes\Repository`** (`@template T of object`, abstract CRUD) — портирован с
`eoffice_v3/System/Classes/Repository.php` (пользователь явно попросил именно этот паттерн), но НЕ
один-в-один — два сознательных отличия от оригинала (см. CLAUDE.md → раздел DataBase за подробностями):
1. `getList()` по умолчанию `PDO::FETCH_CLASS` с проброшенным `$this->obj_class` (в оригинале —
`PDO::FETCH_KEY_PAIR` по умолчанию, и `FETCH_CLASS` не получает `obj_class` вовсе — нестыковка
PHPDoc/кода в оригинале, не повторяли).
2. Транзакции (`beginTransaction/commit/rollBack`) — тонкие делегаты в `PdoDriver` (см. выше), а не свой
счётчик вложенности в каждом `Repository`, как в оригинале (там это ломается, если два репозитория
на одном соединении оба вызывают `beginTransaction()` — оба думают, что «внешние»).
Методы: `get()/getItemWhere()/getList()/create()/update()/delete()/deleteWhere()`, `primary_col`/`is_auto_increment`
публичные, `processData()` — сборка bind-параметров с автоопределением `PDO::PARAM_*`.
Конкретные репозитории — в `App/Repositories/*` (например `App\Repositories\BudgetRepository`), НЕ в
`Services\DataBase` — так решил пользователь явно; таблица/класс строки задаются через конструктор
(`parent::__construct('budget', Model::class)`), не переопределением свойства.
- **`Services\DataBase\Model`** (`#[AllowDynamicProperties]`) — класс строки по умолчанию для `T`.
Важно: свойства настоящие (не спрятаны за внутренним `$attributes`-массивом) — `PDO::FETCH_CLASS`/
`fetchObject()` выставляет их напрямую, **минуя `__set()`** (внутренний механизм PDO, до вызова
конструктора). Версия со скрытым массивом (первая, что я написал) была рабочей только для ручного
`new Model([...])`, но ломалась именно в главном сценарии использования — пользователь сам это поймал
и прислал правильный вариант.
- **Elasticsearch** — `Services\Elasticsearch` + `Services\Elasticsearch\Client`, тонкая REST-обёртка на - **Elasticsearch** — `Services\Elasticsearch` + `Services\Elasticsearch\Client`, тонкая REST-обёртка на
уже существующем `System\Classes\HTTP\Client\Curl` (без тяжёлой composer-зависимости уже существующем `System\Classes\HTTP\Client\Curl` (без тяжёлой composer-зависимости
`elasticsearch/elasticsearch` — см. `feedback_educational_project.md`). `elasticsearch/elasticsearch` — см. `feedback_educational_project.md`).
@ -22,6 +41,27 @@ metadata:
(композер-пакет `mongodb/mongodb` уже поставлен через `composer.phar require --ignore-platform-req=ext-mongodb`, (композер-пакет `mongodb/mongodb` уже поставлен через `composer.phar require --ignore-platform-req=ext-mongodb`,
плюс `"platform": {"ext-mongodb": "2.3"}` в `composer.json`, чтобы дальнейшие `composer install` не плюс `"platform": {"ext-mongodb": "2.3"}` в `composer.json`, чтобы дальнейшие `composer install` не
требовали флага). требовали флага).
- **`System\Classes\ProfilerToolbar`** — debug-панель внизу страницы (аналог ProfilerToolbar для Kohana),
время/память/SQL из `Profiler`, видна только `role === 'admin'`, подключена в `App/view/layout.html`.
**Профилирование — в `Statement::execute()`, не в `PdoDriver::query()` (важно, был реальный баг):**
первая версия вешала таймер только на `PdoDriver::query()` через отдельный класс `ProfilerPDO::wrap()`
(теперь удалён) — но `Repository::get()/getList()/create()/update()/delete()/deleteWhere()` сами делают
`$this->pdo->prepare($sql); $stmt->execute();` напрямую, в обход `query()` — то есть почти все реальные
запросы через `Repository` не логировались вообще (пользователь поймал это по `ProfilerToolbar`,
показывавшему "SQL: 0" при заведомо выполненном запросе). Раз `PDO::ATTR_STATEMENT_CLASS` гарантирует,
что через `Statement` проходит вообще любое выполнение запроса (и `query()`, и ручной `prepare()+execute()`),
таймер и вызов `Profiler::log()` теперь в `Statement::execute()` — единственной точке, которую нельзя
обойти. Общий урок: если добавляешь профилирование/логирование поверх PDO-обёртки — вешать его на
низкоуровневый общий метод (`Statement`/`PDOStatement`), а не на удобный высокоуровневый (`PdoDriver::query()`),
если у обёртки есть другие пути выполнения запроса в обход этого высокоуровневого метода.
**Свойство `Repository`/`PdoDriver` называется `$pdo`** (не `$_driver`/`$_pdo` с подчёркиванием, хотя это
нарушает общий стиль `_prefixed` protected-свойств проекта) — пользователь явно попросил именно так,
дважды сам написал `$this->pdo` в своём коде раньше, чем я успел объяснить структуру. См. также
`PdoDriver::rollback()` — с маленькой буквы (не `rollBack()`, хотя нативный `\PDO::rollBack()` — с
большой); `Repository::rollBack()` (с большой буквы) — публичный метод, который делегирует в
`$this->pdo->rollback()` (с маленькой) — это НЕ опечатка, два разных метода двух разных классов.
**Что ждёт внешней инфраструктуры (не моя зона — пользователь занимается сам):** **Что ждёт внешней инфраструктуры (не моя зона — пользователь занимается сам):**
@ -29,19 +69,23 @@ metadata:
на хосте — мне трогать нельзя). Пользователь добавит сам. Код написан безопасно без расширения — на хосте — мне трогать нельзя). Пользователь добавит сам. Код написан безопасно без расширения —
падает только на реальном `new MongoDB\Client()` внутри `MongoDriver::__construct()`, не раньше. падает только на реальном `new MongoDB\Client()` внутри `MongoDriver::__construct()`, не раньше.
- Контейнеров `mongo` и `elasticsearch` в `docker-dev/docker-compose.yml` нет — пользователь поднимет сам - Контейнеров `mongo` и `elasticsearch` в `docker-dev/docker-compose.yml` нет — пользователь поднимет сам
(there уже есть закомментированный черновик блока `elasticsearch` в файле). (там уже есть закомментированный черновик блока `elasticsearch` в файле).
- БД `bicycle` на общем MariaDB (контейнер `db`) ещё не создана, `App/config/config.local.php` пустой - MariaDB — пользователь сам прописал в `App/config/config.php`/`config.local.php` реальное подключение
(0 байт) — реальный пароль/пользователь для MariaDB, а также для Mongo/ES (если с авторизацией) — (внешний хост, не контейнер `db` из docker-dev) — эта часть инфраструктуры уже готова пользователем,
пользователь впишет сам. тесты по MariaDB реально гоняются, не skip.
**Тесты**: `PdoDriverTest`/`StatementTest`/`RepositoryTest` бьют по реальной MariaDB (не мокают) — пока **Тесты**: `PdoDriverTest`/`StatementTest`/`RepositoryTest` бьют по реальной MariaDB (не мокают) — гейт
БД не настроена, гейтятся через `markTestSkipped()` в `setUp()` при неудачном подключении. через `markTestSkipped()` в `setUp()` при неудачном подключении остался в коде на случай, если у
`ElasticsearchClientTest` — то же самое через `Client::ping()`. `MongoDriverTest` — через кого-то ещё не настроен `config.local.php`, но сейчас реально проходят (не skip). `ElasticsearchClientTest` —
`extension_loaded('mongodb')`. Все они оживут сами, без правки кода, как только пользователь настроит через `Client::ping()`. `MongoDriverTest` — через `extension_loaded('mongodb')`. Эти два оживут сами,
соответствующую инфраструктуру — сейчас (2026-08-07) 26 тестов из 229 пропущены именно поэтому. без правки кода, когда появится инфраструктура — сейчас (2026-08-07) 8 тестов из 239 пропущены именно
поэтому.
**How to apply:** При запросах, касающихся БД/моделей/репозиториев — этот слой уже есть, не писать **How to apply:** При запросах, касающихся БД/моделей/репозиториев — этот слой уже есть, не писать
заново с нуля, расширять существующие классы. Новый конкретный репозиторий — в `App/Repositories/* заново с нуля, расширять существующие классы. Новый конкретный репозиторий — в `App/Repositories/*
extends System\Classes\Repository`, не в `Services\DataBase`. См. также `idea_cli_entrypoint.md` — extends System\Classes\Repository`, не в `Services\DataBase`. Если пользователь присылает код из другого
CLI-точка входа отложена именно до появления этого слоя (первый реальный потребитель — миграции; своего проекта («у меня так работает») как референс — не копировать один-в-один вслепую: проверять на
теперь, когда слой БД есть, эта идея стала актуальнее). реальные баги/нестыковки (как с `getList()`/`FETCH_CLASS` и дублированием transaction-счётчика выше) и
чинить под Bicycle, если он прямо говорит «не так же, а правильно». См. также `idea_cli_entrypoint.md` —
CLI-точка входа отложена именно до появления этого слоя (первый реальный потребитель — миграции; теперь,
когда слой БД есть, эта идея стала актуальнее).

View File

@ -12,6 +12,7 @@ use App\Repositories\BudgetRepository;
use System\Classes\Controller; use System\Classes\Controller;
use System\Classes\HTTP\Client\Curl; use System\Classes\HTTP\Client\Curl;
use System\Classes\HTTP\Client\Request as HttpRequest; use System\Classes\HTTP\Client\Request as HttpRequest;
use System\Classes\MyException;
/** /**
* Демонстрационный контроллер главной страницы. * Демонстрационный контроллер главной страницы.
@ -22,22 +23,22 @@ class IndexController extends Controller
* Главная страница: GET-запрос к внешнему API, вывод todo в шаблон. * Главная страница: GET-запрос к внешнему API, вывод todo в шаблон.
* *
* @return string * @return string
* @throws \System\Classes\MyException * @throws MyException
*/ */
public function indexAction(): string public function indexAction(): string
{ {
$budgetR = new BudgetRepository(); $budgetR = new BudgetRepository();
var_dump($budgetR->getLists());die; $budgetR->getB();
return $this->render('index', ['todo' => $todo]); return $this->render('index');
} }
/** /**
* Демонстрация POST-запроса с JSON-телом к внешнему API. * Демонстрация POST-запроса с JSON-телом к внешнему API.
* *
* @return string * @return string
* @throws \System\Classes\MyException * @throws \JsonException
*/ */
public function postAction(): string public function postAction(): string
{ {

View File

@ -26,13 +26,13 @@ class BudgetRepository extends Repository
* @return array * @return array
* @throws MyException * @throws MyException
*/ */
public function getLists(): array public function getB(): array
{ {
$sql = "SELECT * FROM budget"; $sql = "SELECT * FROM budget";
$stmt = $this->pdo->prepare($sql); $stmt = $this->pdo->prepare($sql);
$stmt->execute(); $stmt->execute();
return $stmt->fetchAll(PDO::FETCH_CLASS); return $stmt->fetchAll();
} }
} }

View File

@ -43,7 +43,7 @@ App/config/ — конфиги (config.php; config.local.
App/view/ — шаблоны приложения (.html файлы с PHP-кодом) App/view/ — шаблоны приложения (.html файлы с PHP-кодом)
App/media/ — статические ресурсы (js, css, img) App/media/ — статические ресурсы (js, css, img)
Services/Auth/ — авторизация: интерфейс AuthDriver + FileAuthDriver (см. раздел Auth ниже) Services/Auth/ — авторизация: интерфейс AuthDriver + FileAuthDriver (см. раздел Auth ниже)
Services/Database.php, DataBase/ — реляционная БД (MariaDB/PDO): фабрика + Model/Classes/{PdoDriver,Statement,Profiler,ProfilerPDO} (см. раздел DataBase ниже) Services/Database.php, DataBase/ — реляционная БД (MariaDB/PDO): фабрика + Model/Classes/{PdoDriver,Statement,Profiler} (см. раздел DataBase ниже)
Services/Elasticsearch.php, Elasticsearch/ — поиск: фабрика + Client (REST, см. раздел Elasticsearch ниже) Services/Elasticsearch.php, Elasticsearch/ — поиск: фабрика + Client (REST, см. раздел Elasticsearch ниже)
Services/Mongo.php, Mongo/ — документная БД: фабрика + MongoDriver (см. раздел Mongo ниже) Services/Mongo.php, Mongo/ — документная БД: фабрика + MongoDriver (см. раздел Mongo ниже)
Services/Mail/, PDF/ — пустые каталоги-заготовки под будущие сервисы (PHPMailer / dompdf); кода пока нет Services/Mail/, PDF/ — пустые каталоги-заготовки под будущие сервисы (PHPMailer / dompdf); кода пока нет
@ -111,11 +111,10 @@ PhpStorm может показывать предупреждение «Namespac
| `Services\Auth\FileAuthDriver` | Авторизация по файлу пользователей (по умолчанию); позже — БД/LDAP/Keycloak | | `Services\Auth\FileAuthDriver` | Авторизация по файлу пользователей (по умолчанию); позже — БД/LDAP/Keycloak |
| `Services\Database` | Точка входа в реляционную БД (MariaDB): `instance(?string $name = null): PdoDriver`, кэш по имени подключения | | `Services\Database` | Точка входа в реляционную БД (MariaDB): `instance(?string $name = null): PdoDriver`, кэш по имени подключения |
| `Services\DataBase\Classes\PdoDriver` | Обёртка над `PDO`: `query()`/`prepare()`/`exec()`, failover по `hosts`, транзакции с вложенными SAVEPOINT (`transaction()`) | | `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\Statement` | `extends PDOStatement` (через `PDO::ATTR_STATEMENT_CLASS`); `showQuery()`/`sq()` — SQL с подставленными параметрами, для дебага; `execute()` также профилирует в `Profiler` |
| `Services\DataBase\Classes\Profiler` | Статический накопитель таймингов запросов: `log()`, `entries()`, `totalTime()`, `count()`, `reset()` | | `Services\DataBase\Classes\Profiler` | Статический накопитель таймингов запросов: `log()`, `entries()`, `totalTime()`, `count()`, `reset()` |
| `Services\DataBase\Classes\ProfilerPDO` | Адаптер `Profiler` под `PdoDriver::query()`: замеряет время выполнения колбэка | | `Services\DataBase\Model` | `#[AllowDynamicProperties]`, лёгкий пассивный носитель данных строки с настоящими (не спрятанными) свойствами — под `PDO::FETCH_CLASS`; `toArray()` |
| `Services\DataBase\Model` | Лёгкий пассивный носитель данных строки: `__get`/`__set`/`__isset`/`toArray()` | | `System\Classes\Repository` | `@template T`, abstract CRUD поверх таблицы: `get()/getItemWhere()/getList()/create()/update()/delete()/deleteWhere()`, `beginTransaction()/commit()/rollBack()/transaction()` (делегируют в `PdoDriver`); конкретные — в `App\Repositories\*` |
| `System\Classes\Repository` | Abstract CRUD поверх таблицы (`find/all/insert/update/delete/count/transaction`); конкретные — в `App\Repositories\*` |
| `Services\Elasticsearch` | Точка входа в Elasticsearch: `instance(?string $name = null): Client`, кэш по имени подключения | | `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\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` | Точка входа в MongoDB: `instance(?string $name = null): MongoDriver`, кэш по имени подключения |
@ -338,7 +337,8 @@ $driver->transaction(function ($driver) {
``` ```
Тайминги запросов — `Services\DataBase\Classes\Profiler::entries()`/`totalTime()` (наполняется Тайминги запросов — `Services\DataBase\Classes\Profiler::entries()`/`totalTime()` (наполняется
автоматически из `PdoDriver::query()` через `ProfilerPDO`). автоматически из `Statement::execute()` — единой точки для любого выполнения запроса, в т.ч.
ручного `$driver->prepare()->execute()` в обход `query()`, как делает большинство методов `Repository`).
**Repository** — `System\Classes\Repository` (`@template T of object`, abstract CRUD), конкретные — в **Repository** — `System\Classes\Repository` (`@template T of object`, abstract CRUD), конкретные — в
`App/Repositories/*`. Таблица и класс строки задаются через конструктор, не через переопределение `App/Repositories/*`. Таблица и класс строки задаются через конструктор, не через переопределение
@ -373,6 +373,16 @@ $repo->transaction(fn () => /* несколько операций одной т
наследнике при необходимости. `processData()` (protected) — общая сборка bind-параметров для наследнике при необходимости. `processData()` (protected) — общая сборка bind-параметров для
`create()`/`update()` с автоопределением `PDO::PARAM_*` по типу значения (массив кодируется в JSON). `create()`/`update()` с автоопределением `PDO::PARAM_*` по типу значения (массив кодируется в JSON).
`getList()` по умолчанию — `$fetch = PDO::FETCH_CLASS`, и в этом случае в `fetchAll()` явно передаётся
`$this->obj_class` (не generic `stdClass`) — так же, как в `get()`/`getItemWhere()`. Это сознательное
отличие от паттерна, с которого портировали этот класс (`eoffice_v3/System/Classes/Repository.php`):
там по умолчанию `PDO::FETCH_KEY_PAIR` (падает, если `$cols` — не ровно 2 колонки — плохой дефолт
для общего метода) и `FETCH_CLASS` не прокидывает `obj_class` в `fetchAll()` вовсе (реальная
нестыковка между PHPDoc и кодом в оригинале). Транзакции по той же причине не портированы
один-в-один: там свой счётчик вложенности в каждом `Repository`, что ломается при двух репозиториях
на одном соединении (оба думают, что они «внешние», оба зовут `PDO::beginTransaction()` — исключение);
в Bicycle транзакции делегируются в `PdoDriver`, у которого счётчик один на всё подключение.
`Model` (`Services\DataBase\Model`, `#[AllowDynamicProperties]`) — лёгкий пассивный носитель данных, `Model` (`Services\DataBase\Model`, `#[AllowDynamicProperties]`) — лёгкий пассивный носитель данных,
это класс по умолчанию для `T` в `Repository`. Свойства настоящие (не спрятаны за внутренним массивом) — это класс по умолчанию для `T` в `Repository`. Свойства настоящие (не спрятаны за внутренним массивом) —
это принципиально: `PDO::FETCH_CLASS`/`fetchObject()` выставляет их напрямую, **минуя `__set()`** это принципиально: `PDO::FETCH_CLASS`/`fetchObject()` выставляет их напрямую, **минуя `__set()`**
@ -423,13 +433,27 @@ $driver->database()->listCollections();
### ProfilerToolbar (System\Classes\ProfilerToolbar) ### ProfilerToolbar (System\Classes\ProfilerToolbar)
Debug-панель внизу страницы (аналог [ProfilerToolbar для Kohana](https://github.com/Alert/profilertoolbar)): Debug-панель внизу страницы (аналог [ProfilerToolbar для Kohana](https://github.com/Alert/profilertoolbar)) —
время выполнения запроса, пиковая память, список SQL-запросов с параметрами (из портирована не один-в-один: FireBug-вывод (`firebug()`/FirePHP) и подсветка исходников (`debugSource()`/
`Services\DataBase\Classes\Profiler`, см. раздел DataBase выше). Подключена прямо в `App/view/layout.html` GeSHi) из оригинала не переносились — устаревшие технологии, не нужны здесь. Вкладки:
(`<?= \System\Classes\ProfilerToolbar::render() ?>` перед `</body>`) — рендерится для любой страницы
сайта, но `render()` отдаёт пустую строку всем, кроме `role === 'admin'` (проверка через - **SQL** — из `Services\DataBase\Classes\Profiler` (см. раздел DataBase выше): время, текст запроса, параметры,
`Auth::instance()->getUser()`, как и остальные ролевые проверки в проекте). Кликабельная строка сворачивает/ `EXPLAIN` для SELECT-запросов (прогоняется заново через `Database::instance()` в момент рендера панели,
разворачивает таблицу запросов (инлайн `onclick`, без внешнего JS/CSS — панель самодостаточна). не при исполнении самого запроса; ошибка `EXPLAIN` → `—`, не ломает панель). Сам `EXPLAIN` — тоже
обычный `query()`, поэтому попадает в `Profiler`, но счётчик/время в шапке панели ("SQL: N (X ms)")
захватываются до его выполнения — иначе панель считала бы свои же debug-запросы.
- **Vars** — `GET`/`POST`/`COOKIE`/`SESSION`/`SERVER`; чувствительные ключи (`Log::$mask_keys`:
`password`, `pass`, `csrf_token`, `token`) маскируются `***` — та же маска, что и в `Log`.
- **Files** — все подключённые к запросу файлы (`get_included_files()`) с размером и общим итогом.
- **Route** — текущие `uri`/`method`/`controller`/`action`/`params` из `Request::$current`.
- **Custom** — появляется, только если код вызвал `ProfilerToolbar::addData($data, $tab = 'custom')`
(аналог `addData()` в оригинале) — свободная вкладка для точечной отладки из любого места кода.
Подключена прямо в `App/view/layout.html` (`<?= \System\Classes\ProfilerToolbar::render() ?>` перед
`</body>`) — рендерится для любой страницы сайта, но `render()` отдаёт пустую строку всем, кроме
`role === 'admin'` (проверка через `Auth::instance()->getUser()`, как и остальные ролевые проверки
в проекте). Верхняя строка сворачивает/разворачивает панель, кнопки переключают вкладки — инлайн
`onclick` + один `<script>`/`<style>` внутри самого `render()`, без внешних файлов — панель самодостаточна.
### Log (System\Classes\Log) ### Log (System\Classes\Log)
@ -574,18 +598,18 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap
| `tests/Unit/FileAuthDriverTest.php` | `login()` верно/неверно/неизвестный логин, `loggedIn()`, `getUser()` без пароля, `logout()`, `checkPassword()`, отсутствие файла | | `tests/Unit/FileAuthDriverTest.php` | `login()` верно/неверно/неизвестный логин, `loggedIn()`, `getUser()` без пароля, `logout()`, `checkPassword()`, отсутствие файла |
| `tests/Unit/AuthTest.php` | `instance()` драйвер по умолчанию/явный, кэширование по драйверу, неизвестный драйвер → исключение | | `tests/Unit/AuthTest.php` | `instance()` драйвер по умолчанию/явный, кэширование по драйверу, неизвестный драйвер → исключение |
| `tests/Unit/DatabaseTest.php` | `instance()` кэш по имени подключения, неизвестное подключение/драйвер → исключение | | `tests/Unit/DatabaseTest.php` | `instance()` кэш по имени подключения, неизвестное подключение/драйвер → исключение |
| `tests/Unit/PdoDriverTest.php` | failover (не требует БД), `query()`, транзакции/вложенные SAVEPOINT, `transaction()` — требует живую MariaDB, иначе `markTestSkipped()` | | `tests/Unit/PdoDriverTest.php` | failover (не требует БД), `query()`, транзакции/вложенные SAVEPOINT, `transaction()`, профилирование ручного `prepare()+execute()` — требует живую MariaDB, иначе `markTestSkipped()` |
| `tests/Unit/StatementTest.php` | `showQuery()`/`sq()` — подстановка позиционных/именованных параметров — требует живую MariaDB | | `tests/Unit/StatementTest.php` | `showQuery()`/`sq()` — подстановка позиционных/именованных параметров — требует живую MariaDB |
| `tests/Unit/ProfilerTest.php` | `log()/entries()/totalTime()/count()/reset()`, интеграция через `ProfilerPDO::wrap()` | | `tests/Unit/ProfilerTest.php` | `log()/entries()/totalTime()/count()/reset()` |
| `tests/Unit/RepositoryTest.php` | CRUD, `all()` с `where`, `transaction()` с rollback — требует живую MariaDB | | `tests/Unit/RepositoryTest.php` | `get()/getItemWhere()/getList()` (в т.ч. дефолтный `FETCH_CLASS` + `obj_class`)/`create()/update()/delete()/deleteWhere()`, `transaction()` с rollback — требует живую MariaDB |
| `tests/Unit/ModelTest.php` | `__get/__set/__isset/toArray()` | | `tests/Unit/ModelTest.php` | `__get/__set/__isset/toArray()`, `PDO::FETCH_CLASS`/`fetchObject()` выставляет настоящие свойства (не через `__set()`) |
| `tests/Unit/ElasticsearchTest.php` | `instance()` кэш по имени подключения, неизвестное подключение → исключение | | `tests/Unit/ElasticsearchTest.php` | `instance()` кэш по имени подключения, неизвестное подключение → исключение |
| `tests/Unit/ElasticsearchClientTest.php` | `index()/get()/delete()/exists()` — требует живой Elasticsearch, иначе `markTestSkipped()` через `ping()` | | `tests/Unit/ElasticsearchClientTest.php` | `index()/get()/delete()/exists()` — требует живой Elasticsearch, иначе `markTestSkipped()` через `ping()` |
| `tests/Unit/MongoTest.php` | неизвестное подключение → исключение (без ext-mongodb — остальное см. MongoDriverTest) | | `tests/Unit/MongoTest.php` | неизвестное подключение → исключение (без ext-mongodb — остальное см. MongoDriverTest) |
| `tests/Unit/MongoDriverTest.php` | `instance()`, `collection()`, `database()` — требует `ext-mongodb`, иначе `markTestSkipped()` | | `tests/Unit/MongoDriverTest.php` | `instance()`, `collection()`, `database()` — требует `ext-mongodb`, иначе `markTestSkipped()` |
| `tests/Unit/ProfilerToolbarTest.php` | `render()` пусто для гостя/не-admin, панель с временем/памятью/SQL для admin | | `tests/Unit/ProfilerToolbarTest.php` | `render()` пусто для гостя/не-admin, панель с временем/памятью/SQL для admin, вкладки Vars/Files/Route, маскировка чувствительных ключей, вкладка Custom только при `addData()`, `EXPLAIN` для реального SELECT / пропуск для не-SELECT |
**233 теста, 335 assertion — все проходят (26 skipped: MariaDB/Elasticsearch/Mongo без живой инфраструктуры — см. разделы DataBase/Elasticsearch/Mongo выше).** **242 теста, 379 assertion — все проходят (8 skipped: Elasticsearch/Mongo без живой инфраструктуры — MariaDB подключена и все её тесты реально проходят, см. разделы DataBase/Elasticsearch/Mongo выше).**
### Frontend dependencies (через Composer) ### Frontend dependencies (через Composer)

View File

@ -100,7 +100,7 @@ class PdoDriver
} }
/** /**
* Готовит, выполняет (с профилированием времени — см. ProfilerPDO) и возвращает выражение. * Готовит и выполняет запрос (профилирование времени — см. Statement::execute()).
* *
* @param string $sql SQL-запрос * @param string $sql SQL-запрос
* @param array $params Параметры для execute() * @param array $params Параметры для execute()
@ -110,8 +110,7 @@ class PdoDriver
public function query(string $sql, array $params = []): false|PDOStatement public function query(string $sql, array $params = []): false|PDOStatement
{ {
$statement = $this->prepare($sql); $statement = $this->prepare($sql);
$statement->execute($params);
ProfilerPDO::wrap($sql, $params, static fn () => $statement->execute($params));
return $statement; return $statement;
} }

View File

@ -11,7 +11,8 @@ namespace Services\DataBase\Classes;
/** /**
* Накопитель таймингов запросов к БД — статический реестр на весь запрос * Накопитель таймингов запросов к БД — статический реестр на весь запрос
* (по образцу Log). Backend-agnostic сам по себе (принимает произвольный $driver), * (по образцу Log). Backend-agnostic сам по себе (принимает произвольный $driver),
* заполняется адаптерами вроде ProfilerPDO. * заполняется из Statement::execute() — единственной точки, через которую проходит
* вообще любое выполнение запроса (в т.ч. ручной $pdo->prepare()->execute()).
*/ */
class Profiler class Profiler
{ {

View File

@ -1,34 +0,0 @@
<?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

@ -16,6 +16,9 @@ use PDOStatement;
* (см. PdoDriver::connect()). Добавляет showQuery()/sq() — PDOStatement::debugDumpParams() * (см. PdoDriver::connect()). Добавляет showQuery()/sq() — PDOStatement::debugDumpParams()
* печатает в stdout, а не возвращает строку, поэтому значения bind-параметров * печатает в stdout, а не возвращает строку, поэтому значения bind-параметров
* копятся вручную (bindValue()/execute() переопределены) и подставляются в SQL сами. * копятся вручную (bindValue()/execute() переопределены) и подставляются в SQL сами.
* execute() также профилирует время в Profiler — здесь, а не в PdoDriver::query(),
* потому что через ATTR_STATEMENT_CLASS проходит вообще любое выполнение запроса,
* в том числе ручной $pdo->prepare()->execute() в обход query() (как в Repository).
*/ */
class Statement extends PDOStatement class Statement extends PDOStatement
{ {
@ -50,7 +53,12 @@ class Statement extends PDOStatement
$this->_bound_params[$key] = $value; $this->_bound_params[$key] = $value;
} }
return parent::execute($params); $start = microtime(true);
$result = parent::execute($params);
Profiler::log($this->queryString, $this->_bound_params, (microtime(true) - $start) * 1000);
return $result;
} }
/** /**

View File

@ -9,16 +9,36 @@
namespace System\Classes; namespace System\Classes;
use Services\Auth; use Services\Auth;
use Services\Database;
use Services\DataBase\Classes\Profiler; use Services\DataBase\Classes\Profiler;
/** /**
* Debug-панель внизу страницы: время запроса, память, SQL-запросы * Debug-панель внизу страницы (аналог ProfilerToolbar для Kohana,
* (см. Services\DataBase\Classes\Profiler). Видна только role === 'admin' * github.com/Alert/profilertoolbar): время/память, SQL-запросы
* (см. Services\Auth) — render() отдаёт пустую строку всем остальным. * (Services\DataBase\Classes\Profiler), GET/POST/COOKIE/SESSION/SERVER,
* Аналог ProfilerToolbar для Kohana (github.com/Alert/profilertoolbar). * подключённые файлы, текущий route, произвольные данные через addData().
* Видна только role === 'admin' (Services\Auth) — render() отдаёт пустую
* строку всем остальным. FireBug-вывод и подсветка исходников из оригинала
* не портированы — устаревшие технологии, не нужны для этого проекта.
*/ */
class ProfilerToolbar class ProfilerToolbar
{ {
/** @var array<string,array<int,mixed>> Произвольные данные, добавленные через addData() */
private static array $_custom = [];
/**
* Добавляет произвольные данные во вкладку Custom панели — вызывать из любого места кода
* для отладки (аналог Kohana_ProfilerToolbar::addData()).
*
* @param mixed $data Что угодно — выводится через var_export()
* @param string $tab Имя подвкладки внутри Custom (группировка, если данных несколько)
* @return void
*/
public static function addData(mixed $data, string $tab = 'custom'): void
{
self::$_custom[$tab][] = $data;
}
/** /**
* Рендерит панель, если текущий пользователь — admin, иначе — пустую строку. * Рендерит панель, если текущий пользователь — admin, иначе — пустую строку.
* *
@ -34,19 +54,215 @@ class ProfilerToolbar
$time_ms = (microtime(true) - ($_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true))) * 1000; $time_ms = (microtime(true) - ($_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true))) * 1000;
$memory = memory_get_peak_usage(true); $memory = memory_get_peak_usage(true);
// Захватываем ДО sqlTab(): EXPLAIN внутри неё сам логируется в Profiler (обычный query()
// через тот же PdoDriver) — иначе шапка "SQL: N (X ms)" включила бы наши же debug-запросы.
$query_count = Profiler::count(); $query_count = Profiler::count();
$query_time = Profiler::totalTime(); $query_time = Profiler::totalTime();
$tabs = [
'sql' => ['label' => 'SQL (' . $query_count . ')', 'html' => self::sqlTab()],
'vars' => ['label' => 'Vars', 'html' => self::varsTab()],
'files' => ['label' => 'Files (' . count(get_included_files()) . ')', 'html' => self::filesTab()],
'route' => ['label' => 'Route', 'html' => self::routeTab()],
];
if (self::$_custom !== []) {
$tabs['custom'] = ['label' => 'Custom', 'html' => self::customTab()];
}
return self::html($time_ms, $memory, $query_count, $query_time, $tabs);
}
/**
* @return string HTML вкладки SQL — список запросов с временем и параметрами
*/
private static function sqlTab(): string
{
$rows = ''; $rows = '';
foreach (Profiler::entries() as $entry) { foreach (Profiler::entries() as $entry) {
$rows .= '<tr>' . $rows .= '<tr>' .
'<td>' . number_format($entry['time_ms'], 2) . ' ms</td>' . '<td>' . number_format($entry['time_ms'], 2) . ' ms</td>' .
'<td><code>' . htmlspecialchars($entry['sql'], ENT_QUOTES, 'UTF-8') . '</code></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>' . '<td><code>' . htmlspecialchars(json_encode($entry['params'], JSON_UNESCAPED_UNICODE), ENT_QUOTES, 'UTF-8') . '</code></td>' .
'<td>' . self::explain($entry['sql'], $entry['params']) . '</td>' .
'</tr>'; '</tr>';
} }
return self::html($time_ms, $memory, $query_count, $query_time, $rows); return '<table class="ptb-table"><thead><tr><th>Время</th><th>SQL</th><th>Параметры</th><th>EXPLAIN</th></tr></thead><tbody>' .
$rows . '</tbody></table>';
}
/**
* Прогоняет EXPLAIN для SELECT-запроса тем же подключением (Database::instance()) —
* только для отображения в панели, не влияет на сам запрос. Не для не-SELECT
* и не при ошибке (например запрос уже недействителен вне транзакции) — тогда просто '—'.
*
* @param string $sql Исходный SQL-запрос
* @param array $params Параметры запроса (те же, что были у оригинального запроса)
* @return string HTML-таблица EXPLAIN или '—'
*/
private static function explain(string $sql, array $params): string
{
if (stripos(ltrim($sql), 'select') !== 0) {
return '—';
}
try {
$rows = Database::instance()->query('EXPLAIN ' . $sql, $params)->fetchAll();
} catch (\Throwable) {
return '—';
}
if ($rows === []) {
return '—';
}
$head = '<tr>' . implode('', array_map(
static fn (string $col) => '<th>' . htmlspecialchars($col, ENT_QUOTES, 'UTF-8') . '</th>', array_keys($rows[0])
)) . '</tr>';
$body = '';
foreach ($rows as $row) {
$body .= '<tr>' . implode('', array_map(
static fn ($val) => '<td>' . htmlspecialchars((string) ($val ?? 'NULL'), ENT_QUOTES, 'UTF-8') . '</td>', $row
)) . '</tr>';
}
return '<table class="ptb-table ptb-explain"><thead>' . $head . '</thead><tbody>' . $body . '</tbody></table>';
}
/**
* @return string HTML вкладки Vars — GET/POST/COOKIE/SESSION/SERVER (чувствительные ключи маскируются)
*/
private static function varsTab(): string
{
$request = Request::$current;
$groups = [
'GET' => $request?->query() ?? $_GET,
'POST' => $request?->post() ?? $_POST,
'COOKIE' => $_COOKIE,
'SESSION' => Session::instance()->getData(),
'SERVER' => $_SERVER,
];
$html = '';
foreach ($groups as $name => $data) {
$data = self::mask((array) $data);
$html .= '<h4 class="ptb-h4">' . $name . ' (' . count($data) . ')</h4>' . self::keyValueTable($data);
}
return $html;
}
/**
* @return string HTML вкладки Files — все подключённые к запросу файлы (get_included_files())
*/
private static function filesTab(): string
{
$files = get_included_files();
$rows = '';
$size = 0;
foreach ($files as $i => $file) {
$file_size = is_file($file) ? filesize($file) : 0;
$size += $file_size;
$rows .= '<tr>' .
'<td>' . ($i + 1) . '</td>' .
'<td><code>' . htmlspecialchars($file, ENT_QUOTES, 'UTF-8') . '</code></td>' .
'<td>' . number_format($file_size / 1024, 1) . ' KB</td>' .
'</tr>';
}
return '<p>Всего: ' . count($files) . ' (' . number_format($size / 1024 / 1024, 2) . ' MB)</p>' .
'<table class="ptb-table"><thead><tr><th>№</th><th>Файл</th><th>Размер</th></tr></thead><tbody>' .
$rows . '</tbody></table>';
}
/**
* @return string HTML вкладки Route — текущий контроллер/экшен/параметры
*/
private static function routeTab(): string
{
$request = Request::$current;
if ($request === null) {
return '<p>Request::$current не установлен</p>';
}
return self::keyValueTable([
'uri' => $request->uri(),
'method' => $request->method(),
'controller' => $request->controller(),
'action' => $request->action(),
'params' => json_encode($request->params(), JSON_UNESCAPED_UNICODE),
]);
}
/**
* @return string HTML вкладки Custom — данные, добавленные через addData()
*/
private static function customTab(): string
{
$html = '';
foreach (self::$_custom as $tab => $items) {
$html .= '<h4 class="ptb-h4">' . htmlspecialchars($tab, ENT_QUOTES, 'UTF-8') . ' (' . count($items) . ')</h4>';
foreach ($items as $i => $item) {
$dump = is_string($item) ? $item : var_export($item, true);
$html .= '<pre class="ptb-pre">#' . ($i + 1) . ' ' . htmlspecialchars($dump, ENT_QUOTES, 'UTF-8') . '</pre>';
}
}
return $html;
}
/**
* Рекурсивно маскирует значения чувствительных ключей (см. Log::$mask_keys).
*
* @param array $data
* @return array
*/
private static function mask(array $data): array
{
$mask = array_map('strtolower', Log::$mask_keys);
$result = [];
foreach ($data as $key => $value) {
if (in_array(strtolower((string) $key), $mask, true)) {
$result[$key] = '***';
} elseif (is_array($value)) {
$result[$key] = self::mask($value);
} else {
$result[$key] = $value;
}
}
return $result;
}
/**
* @param array<string,mixed> $data
* @return string HTML-таблица ключ/значение
*/
private static function keyValueTable(array $data): string
{
if ($data === []) {
return '<p class="ptb-empty">пусто</p>';
}
$rows = '';
foreach ($data as $key => $value) {
$value_str = is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE) : (string) $value;
$rows .= '<tr><td><code>' . htmlspecialchars((string) $key, ENT_QUOTES, 'UTF-8') . '</code></td>' .
'<td><code>' . htmlspecialchars($value_str, ENT_QUOTES, 'UTF-8') . '</code></td></tr>';
}
return '<table class="ptb-table"><tbody>' . $rows . '</tbody></table>';
} }
/** /**
@ -54,18 +270,45 @@ class ProfilerToolbar
* @param int $memory Пиковое потребление памяти, байт * @param int $memory Пиковое потребление памяти, байт
* @param int $query_count Число SQL-запросов * @param int $query_count Число SQL-запросов
* @param float $query_time Суммарное время SQL-запросов, мс * @param float $query_time Суммарное время SQL-запросов, мс
* @param string $rows Готовые <tr> со списком запросов * @param array<string,array{label:string,html:string}> $tabs Вкладки панели
* @return string * @return string
*/ */
private static function html(float $time_ms, int $memory, int $query_count, float $query_time, string $rows): string private static function html(float $time_ms, int $memory, int $query_count, float $query_time, array $tabs): string
{ {
$memory_mb = number_format($memory / 1024 / 1024, 2); $memory_mb = number_format($memory / 1024 / 1024, 2);
$time = number_format($time_ms, 1); $time = number_format($time_ms, 1);
$query_ms = number_format($query_time, 1); $query_ms = number_format($query_time, 1);
$buttons = '';
$panels = '';
$first = true;
foreach ($tabs as $name => $tab) {
$display = $first ? 'block' : 'none';
$active = $first ? ' ptb-tab-btn-active' : '';
$buttons .= '<button type="button" class="ptb-tab-btn' . $active . '" onclick="ptbTab(this,\'' . $name . '\')">' .
htmlspecialchars($tab['label'], ENT_QUOTES, 'UTF-8') . '</button>';
$panels .= '<div id="ptb-tab-' . $name . '" class="ptb-tab" style="display:' . $display . '">' . $tab['html'] . '</div>';
$first = false;
}
return <<<HTML return <<<HTML
<div id="profiler-toolbar" style="position:fixed;left:0;right:0;bottom:0;z-index:99999; <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;"> font:12px/1.4 monospace;background:#1b1b1b;color:#ddd;border-top:2px solid #444;">
<style>
#profiler-toolbar .ptb-table {width:100%;border-collapse:collapse;}
#profiler-toolbar .ptb-table th, #profiler-toolbar .ptb-table td {padding:3px 10px;text-align:left;border-bottom:1px solid #333;vertical-align:top;}
#profiler-toolbar .ptb-table th {color:#999;}
#profiler-toolbar .ptb-explain {font-size:11px;}
#profiler-toolbar .ptb-explain td, #profiler-toolbar .ptb-explain th {padding:2px 6px;}
#profiler-toolbar .ptb-h4 {margin:8px 10px 2px;color:#8bc34a;}
#profiler-toolbar .ptb-pre {margin:4px 10px;white-space:pre-wrap;word-break:break-all;}
#profiler-toolbar .ptb-empty {margin:4px 10px;color:#777;}
#profiler-toolbar .ptb-tab-btn {background:none;border:none;color:#ddd;padding:6px 12px;cursor:pointer;font:inherit;border-bottom:2px solid transparent;}
#profiler-toolbar .ptb-tab-btn-active {border-bottom-color:#8bc34a;color:#8bc34a;}
</style>
<div style="display:flex;gap:16px;align-items:center;padding:4px 12px;cursor:pointer;" <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';"> onclick="var b=document.getElementById('profiler-toolbar-body');b.style.display=b.style.display==='none'?'block':'none';">
<strong style="color:#8bc34a;">Profiler</strong> <strong style="color:#8bc34a;">Profiler</strong>
@ -73,18 +316,19 @@ class ProfilerToolbar
<span>память: {$memory_mb} MB</span> <span>память: {$memory_mb} MB</span>
<span>SQL: {$query_count} ({$query_ms} ms)</span> <span>SQL: {$query_count} ({$query_ms} ms)</span>
</div> </div>
<div id="profiler-toolbar-body" style="display:none;max-height:300px;overflow:auto;border-top:1px solid #444;"> <div id="profiler-toolbar-body" style="display:none;border-top:1px solid #444;">
<table style="width:100%;border-collapse:collapse;"> <div style="border-bottom:1px solid #333;">{$buttons}</div>
<thead> <div style="max-height:300px;overflow:auto;">{$panels}</div>
<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>
<script>
function ptbTab(btn, name) {
var root = document.getElementById('profiler-toolbar');
root.querySelectorAll('.ptb-tab').forEach(function (el) { el.style.display = 'none'; });
root.querySelectorAll('.ptb-tab-btn').forEach(function (el) { el.classList.remove('ptb-tab-btn-active'); });
document.getElementById('ptb-tab-' + name).style.display = 'block';
btn.classList.add('ptb-tab-btn-active');
}
</script>
</div> </div>
HTML; HTML;
} }

View File

@ -97,6 +97,7 @@ abstract class Repository
* @param callable $callback * @param callable $callback
* @return mixed * @return mixed
* @throws MyException * @throws MyException
* @throws Throwable
*/ */
public function transaction(callable $callback): mixed public function transaction(callable $callback): mixed
{ {
@ -156,12 +157,12 @@ abstract class Repository
* @param array $where ['table_col' => 'value', ...] (объединяются через AND; значение 'IS NULL'/'IS NOT NULL' — без плейсхолдера) * @param array $where ['table_col' => 'value', ...] (объединяются через AND; значение 'IS NULL'/'IS NOT NULL' — без плейсхолдера)
* @param string $order Колонка сортировки * @param string $order Колонка сортировки
* @param string $sort ASC|DESC * @param string $sort ASC|DESC
* @param int $fetch Режим PDO::FETCH_* * @param int $fetch Режим PDO::FETCH_*; для FETCH_CLASS (по умолчанию) строки — $this->obj_class
* @return bool|T[] * @return bool|T[]
* @throws MyException * @throws MyException
*/ */
public function getList( public function getList(
string $cols, array $where = [], string $order = '', string $sort = 'ASC', int $fetch = PDO::FETCH_KEY_PAIR string $cols, array $where = [], string $order = '', string $sort = 'ASC', int $fetch = PDO::FETCH_CLASS
): bool|array { ): bool|array {
$str = ''; $str = '';
@ -189,7 +190,7 @@ abstract class Repository
$stmt = $this->pdo->prepare($sql); $stmt = $this->pdo->prepare($sql);
$stmt->execute($where); $stmt->execute($where);
return $stmt->fetchAll($fetch); return $fetch === PDO::FETCH_CLASS ? $stmt->fetchAll($fetch, $this->obj_class) : $stmt->fetchAll($fetch);
} }
/** /**

View File

@ -122,6 +122,18 @@ class PdoDriverTest extends TestCase
$this->assertFalse($row); $this->assertFalse($row);
} }
public function testManualPrepareExecuteIsProfiledToo(): void
{
// Регрессия: профилирование должно ловить любой execute(), а не только PdoDriver::query() —
// Repository сам делает $this->pdo->prepare()->execute() напрямую (см. get()/create()/...).
Profiler::reset();
$stmt = $this->driver->prepare('INSERT INTO pdo_driver_test (name) VALUES (?)');
$stmt->execute(['foo']);
$this->assertSame(1, Profiler::count());
}
public function testInTransaction(): void public function testInTransaction(): void
{ {
$this->assertFalse($this->driver->inTransaction()); $this->assertFalse($this->driver->inTransaction());

View File

@ -4,7 +4,6 @@ namespace Tests\Unit;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Services\DataBase\Classes\Profiler; use Services\DataBase\Classes\Profiler;
use Services\DataBase\Classes\ProfilerPDO;
class ProfilerTest extends TestCase class ProfilerTest extends TestCase
{ {
@ -48,13 +47,4 @@ class ProfilerTest extends TestCase
$this->assertSame(0, Profiler::count()); $this->assertSame(0, Profiler::count());
$this->assertSame([], Profiler::entries()); $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

@ -5,8 +5,10 @@ namespace Tests\Unit;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use ReflectionClass; use ReflectionClass;
use Services\Auth; use Services\Auth;
use Services\Database;
use Services\DataBase\Classes\Profiler; use Services\DataBase\Classes\Profiler;
use System\Classes\Config; use System\Classes\Config;
use System\Classes\MyException;
use System\Classes\ProfilerToolbar; use System\Classes\ProfilerToolbar;
use System\Classes\Session; use System\Classes\Session;
@ -45,6 +47,16 @@ class ProfilerToolbarTest extends TestCase
$prop = $ref->getProperty('config'); $prop = $ref->getProperty('config');
$prop->setAccessible(true); $prop->setAccessible(true);
$prop->setValue(null, null); $prop->setValue(null, null);
$ref = new ReflectionClass(ProfilerToolbar::class);
$prop = $ref->getProperty('_custom');
$prop->setAccessible(true);
$prop->setValue(null, []);
$ref = new ReflectionClass(Database::class);
$prop = $ref->getProperty('_instances');
$prop->setAccessible(true);
$prop->setValue(null, []);
} }
public function testReturnsEmptyStringForGuest(): void public function testReturnsEmptyStringForGuest(): void
@ -79,4 +91,74 @@ class ProfilerToolbarTest extends TestCase
$this->assertStringContainsString('SELECT * FROM users WHERE id = ?', $html); $this->assertStringContainsString('SELECT * FROM users WHERE id = ?', $html);
$this->assertStringContainsString('42', $html); $this->assertStringContainsString('42', $html);
} }
public function testHasVarsFilesAndRouteTabs(): void
{
Session::instance()->set('auth_user', 'admin');
$html = ProfilerToolbar::render();
$this->assertStringContainsString('Vars', $html);
$this->assertStringContainsString('Files', $html);
$this->assertStringContainsString('Route', $html);
}
public function testVarsTabMasksSensitiveKeys(): void
{
Session::instance()->set('auth_user', 'admin');
$_POST['password'] = 'super-secret';
$html = ProfilerToolbar::render();
$this->assertStringNotContainsString('super-secret', $html);
$this->assertStringContainsString('***', $html);
unset($_POST['password']);
}
public function testExplainShownForRealSelect(): void
{
try {
Database::instance()->exec('DROP TABLE IF EXISTS profiler_toolbar_test');
Database::instance()->exec('CREATE TABLE profiler_toolbar_test (id INT PRIMARY KEY)');
} catch (MyException $e) {
$this->markTestSkipped('Нет живого подключения к MariaDB: ' . $e->getMessage());
}
Session::instance()->set('auth_user', 'admin');
Database::instance()->query('SELECT * FROM profiler_toolbar_test WHERE id = ?', [1]);
$html = ProfilerToolbar::render();
Database::instance()->exec('DROP TABLE IF EXISTS profiler_toolbar_test');
$this->assertStringContainsString('ptb-explain', $html);
$this->assertStringContainsString('select_type', $html);
}
public function testExplainSkippedForNonSelect(): void
{
Session::instance()->set('auth_user', 'admin');
Profiler::log('DELETE FROM users WHERE id = ?', [1], 0.5);
$html = ProfilerToolbar::render();
// 'ptb-explain' сам по себе — ещё и имя CSS-класса в <style>, оно есть всегда;
// маркер реального EXPLAIN-результата — заголовок его колонки 'select_type'.
$this->assertStringNotContainsString('select_type', $html);
}
public function testCustomTabAppearsOnlyWhenDataAdded(): void
{
Session::instance()->set('auth_user', 'admin');
$this->assertStringNotContainsString('Custom', ProfilerToolbar::render());
ProfilerToolbar::addData(['foo' => 'bar'], 'my-debug');
$html = ProfilerToolbar::render();
$this->assertStringContainsString('Custom', $html);
$this->assertStringContainsString('my-debug', $html);
$this->assertStringContainsString('foo', $html);
}
} }

View File

@ -80,7 +80,7 @@ class RepositoryTest extends TestCase
$this->repository->create(['name' => 'b', 'qty' => 2]); $this->repository->create(['name' => 'b', 'qty' => 2]);
$this->repository->create(['name' => 'a', 'qty' => 5]); $this->repository->create(['name' => 'a', 'qty' => 5]);
$rows = $this->repository->getList('*', ['name' => 'a']); $rows = $this->repository->getList('*', ['name' => 'a'], '', 'ASC', PDO::FETCH_ASSOC);
$this->assertCount(2, $rows); $this->assertCount(2, $rows);
} }
@ -129,7 +129,7 @@ class RepositoryTest extends TestCase
$this->repository->deleteWhere('name = ?', ['a']); $this->repository->deleteWhere('name = ?', ['a']);
$this->assertCount(1, $this->repository->getList('*')); $this->assertCount(1, $this->repository->getList('*', [], '', 'ASC', PDO::FETCH_ASSOC));
} }
public function testTransactionRollsBackOnException(): void public function testTransactionRollsBackOnException(): void
@ -143,7 +143,7 @@ class RepositoryTest extends TestCase
} catch (\RuntimeException) { } catch (\RuntimeException) {
} }
$this->assertCount(0, $this->repository->getList('*', ['name' => 'should_rollback'])); $this->assertCount(0, $this->repository->getList('*', ['name' => 'should_rollback'], '', 'ASC', PDO::FETCH_ASSOC));
} }
public function testGetListFetchAssoc(): void public function testGetListFetchAssoc(): void
@ -154,4 +154,14 @@ class RepositoryTest extends TestCase
$this->assertIsArray($rows[0]); $this->assertIsArray($rows[0]);
$this->assertSame('foo', $rows[0]['name']); $this->assertSame('foo', $rows[0]['name']);
} }
public function testGetListDefaultFetchClassUsesObjClass(): void
{
// По умолчанию (без явного $fetch) getList() отдаёт $this->obj_class — не generic stdClass.
$this->repository->create(['name' => 'foo', 'qty' => 1]);
$rows = $this->repository->getList('*');
$this->assertInstanceOf(\Services\DataBase\Model::class, $rows[0]);
$this->assertSame('foo', $rows[0]->name);
}
} }