dev
This commit is contained in:
parent
9d4edf147a
commit
9ff9cc54e6
@ -11,5 +11,6 @@
|
||||
- [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) — 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 для миграций/крона
|
||||
|
||||
36
.claude/memory/feedback_reference_code_handling.md
Normal file
36
.claude/memory/feedback_reference_code_handling.md
Normal 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`.
|
||||
@ -9,12 +9,31 @@ metadata:
|
||||
без общего интерфейса поверх всех трёх (SQL/документы/поиск — слишком разные модели, чтобы прятать
|
||||
за одной абстракцией). Подробности использования — CLAUDE.md → разделы DataBase/Elasticsearch/Mongo.
|
||||
|
||||
- **Реляционная БД (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` — так решил пользователь явно.
|
||||
- **Реляционная БД (MariaDB, полностью рабочая и протестированная, реальное подключение)** —
|
||||
`Services\Database` (фабрика), `Services\DataBase\Classes\PdoDriver` (failover по хостам, транзакции
|
||||
с вложенными SAVEPOINT — единственный источник истины по вложенности, общий на всё подключение),
|
||||
`Services\DataBase\Classes\Statement` (`showQuery()`/`sq()`, `execute()` профилирует в `Profiler`), `Services\DataBase\Classes\Profiler`
|
||||
(тайминги, см. также `System\Classes\ProfilerToolbar`).
|
||||
- **`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-обёртка на
|
||||
уже существующем `System\Classes\HTTP\Client\Curl` (без тяжёлой composer-зависимости
|
||||
`elasticsearch/elasticsearch` — см. `feedback_educational_project.md`).
|
||||
@ -22,6 +41,27 @@ metadata:
|
||||
(композер-пакет `mongodb/mongodb` уже поставлен через `composer.phar require --ignore-platform-req=ext-mongodb`,
|
||||
плюс `"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()`, не раньше.
|
||||
- Контейнеров `mongo` и `elasticsearch` в `docker-dev/docker-compose.yml` нет — пользователь поднимет сам
|
||||
(there уже есть закомментированный черновик блока `elasticsearch` в файле).
|
||||
- БД `bicycle` на общем MariaDB (контейнер `db`) ещё не создана, `App/config/config.local.php` пустой
|
||||
(0 байт) — реальный пароль/пользователь для MariaDB, а также для Mongo/ES (если с авторизацией) —
|
||||
пользователь впишет сам.
|
||||
(там уже есть закомментированный черновик блока `elasticsearch` в файле).
|
||||
- MariaDB — пользователь сам прописал в `App/config/config.php`/`config.local.php` реальное подключение
|
||||
(внешний хост, не контейнер `db` из docker-dev) — эта часть инфраструктуры уже готова пользователем,
|
||||
тесты по MariaDB реально гоняются, не skip.
|
||||
|
||||
**Тесты**: `PdoDriverTest`/`StatementTest`/`RepositoryTest` бьют по реальной MariaDB (не мокают) — пока
|
||||
БД не настроена, гейтятся через `markTestSkipped()` в `setUp()` при неудачном подключении.
|
||||
`ElasticsearchClientTest` — то же самое через `Client::ping()`. `MongoDriverTest` — через
|
||||
`extension_loaded('mongodb')`. Все они оживут сами, без правки кода, как только пользователь настроит
|
||||
соответствующую инфраструктуру — сейчас (2026-08-07) 26 тестов из 229 пропущены именно поэтому.
|
||||
**Тесты**: `PdoDriverTest`/`StatementTest`/`RepositoryTest` бьют по реальной MariaDB (не мокают) — гейт
|
||||
через `markTestSkipped()` в `setUp()` при неудачном подключении остался в коде на случай, если у
|
||||
кого-то ещё не настроен `config.local.php`, но сейчас реально проходят (не skip). `ElasticsearchClientTest` —
|
||||
через `Client::ping()`. `MongoDriverTest` — через `extension_loaded('mongodb')`. Эти два оживут сами,
|
||||
без правки кода, когда появится инфраструктура — сейчас (2026-08-07) 8 тестов из 239 пропущены именно
|
||||
поэтому.
|
||||
|
||||
**How to apply:** При запросах, касающихся БД/моделей/репозиториев — этот слой уже есть, не писать
|
||||
заново с нуля, расширять существующие классы. Новый конкретный репозиторий — в `App/Repositories/*
|
||||
extends System\Classes\Repository`, не в `Services\DataBase`. См. также `idea_cli_entrypoint.md` —
|
||||
CLI-точка входа отложена именно до появления этого слоя (первый реальный потребитель — миграции;
|
||||
теперь, когда слой БД есть, эта идея стала актуальнее).
|
||||
extends System\Classes\Repository`, не в `Services\DataBase`. Если пользователь присылает код из другого
|
||||
своего проекта («у меня так работает») как референс — не копировать один-в-один вслепую: проверять на
|
||||
реальные баги/нестыковки (как с `getList()`/`FETCH_CLASS` и дублированием transaction-счётчика выше) и
|
||||
чинить под Bicycle, если он прямо говорит «не так же, а правильно». См. также `idea_cli_entrypoint.md` —
|
||||
CLI-точка входа отложена именно до появления этого слоя (первый реальный потребитель — миграции; теперь,
|
||||
когда слой БД есть, эта идея стала актуальнее).
|
||||
|
||||
@ -12,6 +12,7 @@ use App\Repositories\BudgetRepository;
|
||||
use System\Classes\Controller;
|
||||
use System\Classes\HTTP\Client\Curl;
|
||||
use System\Classes\HTTP\Client\Request as HttpRequest;
|
||||
use System\Classes\MyException;
|
||||
|
||||
/**
|
||||
* Демонстрационный контроллер главной страницы.
|
||||
@ -22,22 +23,22 @@ class IndexController extends Controller
|
||||
* Главная страница: GET-запрос к внешнему API, вывод todo в шаблон.
|
||||
*
|
||||
* @return string
|
||||
* @throws \System\Classes\MyException
|
||||
* @throws MyException
|
||||
*/
|
||||
public function indexAction(): string
|
||||
{
|
||||
$budgetR = new BudgetRepository();
|
||||
|
||||
var_dump($budgetR->getLists());die;
|
||||
$budgetR->getB();
|
||||
|
||||
return $this->render('index', ['todo' => $todo]);
|
||||
return $this->render('index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Демонстрация POST-запроса с JSON-телом к внешнему API.
|
||||
*
|
||||
* @return string
|
||||
* @throws \System\Classes\MyException
|
||||
* @throws \JsonException
|
||||
*/
|
||||
public function postAction(): string
|
||||
{
|
||||
|
||||
@ -26,13 +26,13 @@ class BudgetRepository extends Repository
|
||||
* @return array
|
||||
* @throws MyException
|
||||
*/
|
||||
public function getLists(): array
|
||||
public function getB(): array
|
||||
{
|
||||
$sql = "SELECT * FROM budget";
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute();
|
||||
|
||||
return $stmt->fetchAll(PDO::FETCH_CLASS);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
}
|
||||
|
||||
62
CLAUDE.md
62
CLAUDE.md
@ -43,7 +43,7 @@ App/config/ — конфиги (config.php; config.local.
|
||||
App/view/ — шаблоны приложения (.html файлы с PHP-кодом)
|
||||
App/media/ — статические ресурсы (js, css, img)
|
||||
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/Mongo.php, Mongo/ — документная БД: фабрика + MongoDriver (см. раздел Mongo ниже)
|
||||
Services/Mail/, PDF/ — пустые каталоги-заготовки под будущие сервисы (PHPMailer / dompdf); кода пока нет
|
||||
@ -111,11 +111,10 @@ PhpStorm может показывать предупреждение «Namespac
|
||||
| `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\Statement` | `extends PDOStatement` (через `PDO::ATTR_STATEMENT_CLASS`); `showQuery()`/`sq()` — SQL с подставленными параметрами, для дебага; `execute()` также профилирует в `Profiler` |
|
||||
| `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\DataBase\Model` | `#[AllowDynamicProperties]`, лёгкий пассивный носитель данных строки с настоящими (не спрятанными) свойствами — под `PDO::FETCH_CLASS`; `toArray()` |
|
||||
| `System\Classes\Repository` | `@template T`, abstract CRUD поверх таблицы: `get()/getItemWhere()/getList()/create()/update()/delete()/deleteWhere()`, `beginTransaction()/commit()/rollBack()/transaction()` (делегируют в `PdoDriver`); конкретные — в `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`, кэш по имени подключения |
|
||||
@ -338,7 +337,8 @@ $driver->transaction(function ($driver) {
|
||||
```
|
||||
|
||||
Тайминги запросов — `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), конкретные — в
|
||||
`App/Repositories/*`. Таблица и класс строки задаются через конструктор, не через переопределение
|
||||
@ -373,6 +373,16 @@ $repo->transaction(fn () => /* несколько операций одной т
|
||||
наследнике при необходимости. `processData()` (protected) — общая сборка bind-параметров для
|
||||
`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]`) — лёгкий пассивный носитель данных,
|
||||
это класс по умолчанию для `T` в `Repository`. Свойства настоящие (не спрятаны за внутренним массивом) —
|
||||
это принципиально: `PDO::FETCH_CLASS`/`fetchObject()` выставляет их напрямую, **минуя `__set()`**
|
||||
@ -423,13 +433,27 @@ $driver->database()->listCollections();
|
||||
|
||||
### 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 — панель самодостаточна).
|
||||
Debug-панель внизу страницы (аналог [ProfilerToolbar для Kohana](https://github.com/Alert/profilertoolbar)) —
|
||||
портирована не один-в-один: FireBug-вывод (`firebug()`/FirePHP) и подсветка исходников (`debugSource()`/
|
||||
GeSHi) из оригинала не переносились — устаревшие технологии, не нужны здесь. Вкладки:
|
||||
|
||||
- **SQL** — из `Services\DataBase\Classes\Profiler` (см. раздел DataBase выше): время, текст запроса, параметры,
|
||||
`EXPLAIN` для SELECT-запросов (прогоняется заново через `Database::instance()` в момент рендера панели,
|
||||
не при исполнении самого запроса; ошибка `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)
|
||||
|
||||
@ -574,18 +598,18 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap
|
||||
| `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/PdoDriverTest.php` | failover (не требует БД), `query()`, транзакции/вложенные SAVEPOINT, `transaction()`, профилирование ручного `prepare()+execute()` — требует живую 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/ProfilerTest.php` | `log()/entries()/totalTime()/count()/reset()` |
|
||||
| `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()`, `PDO::FETCH_CLASS`/`fetchObject()` выставляет настоящие свойства (не через `__set()`) |
|
||||
| `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 |
|
||||
| `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)
|
||||
|
||||
|
||||
@ -100,7 +100,7 @@ class PdoDriver
|
||||
}
|
||||
|
||||
/**
|
||||
* Готовит, выполняет (с профилированием времени — см. ProfilerPDO) и возвращает выражение.
|
||||
* Готовит и выполняет запрос (профилирование времени — см. Statement::execute()).
|
||||
*
|
||||
* @param string $sql SQL-запрос
|
||||
* @param array $params Параметры для execute()
|
||||
@ -110,8 +110,7 @@ class PdoDriver
|
||||
public function query(string $sql, array $params = []): false|PDOStatement
|
||||
{
|
||||
$statement = $this->prepare($sql);
|
||||
|
||||
ProfilerPDO::wrap($sql, $params, static fn () => $statement->execute($params));
|
||||
$statement->execute($params);
|
||||
|
||||
return $statement;
|
||||
}
|
||||
|
||||
@ -11,7 +11,8 @@ namespace Services\DataBase\Classes;
|
||||
/**
|
||||
* Накопитель таймингов запросов к БД — статический реестр на весь запрос
|
||||
* (по образцу Log). Backend-agnostic сам по себе (принимает произвольный $driver),
|
||||
* заполняется адаптерами вроде ProfilerPDO.
|
||||
* заполняется из Statement::execute() — единственной точки, через которую проходит
|
||||
* вообще любое выполнение запроса (в т.ч. ручной $pdo->prepare()->execute()).
|
||||
*/
|
||||
class Profiler
|
||||
{
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,9 @@ use PDOStatement;
|
||||
* (см. PdoDriver::connect()). Добавляет showQuery()/sq() — PDOStatement::debugDumpParams()
|
||||
* печатает в stdout, а не возвращает строку, поэтому значения bind-параметров
|
||||
* копятся вручную (bindValue()/execute() переопределены) и подставляются в SQL сами.
|
||||
* execute() также профилирует время в Profiler — здесь, а не в PdoDriver::query(),
|
||||
* потому что через ATTR_STATEMENT_CLASS проходит вообще любое выполнение запроса,
|
||||
* в том числе ручной $pdo->prepare()->execute() в обход query() (как в Repository).
|
||||
*/
|
||||
class Statement extends PDOStatement
|
||||
{
|
||||
@ -50,7 +53,12 @@ class Statement extends PDOStatement
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -9,16 +9,36 @@
|
||||
namespace System\Classes;
|
||||
|
||||
use Services\Auth;
|
||||
use Services\Database;
|
||||
use Services\DataBase\Classes\Profiler;
|
||||
|
||||
/**
|
||||
* Debug-панель внизу страницы: время запроса, память, SQL-запросы
|
||||
* (см. Services\DataBase\Classes\Profiler). Видна только role === 'admin'
|
||||
* (см. Services\Auth) — render() отдаёт пустую строку всем остальным.
|
||||
* Аналог ProfilerToolbar для Kohana (github.com/Alert/profilertoolbar).
|
||||
* Debug-панель внизу страницы (аналог ProfilerToolbar для Kohana,
|
||||
* github.com/Alert/profilertoolbar): время/память, SQL-запросы
|
||||
* (Services\DataBase\Classes\Profiler), GET/POST/COOKIE/SESSION/SERVER,
|
||||
* подключённые файлы, текущий route, произвольные данные через addData().
|
||||
* Видна только role === 'admin' (Services\Auth) — render() отдаёт пустую
|
||||
* строку всем остальным. FireBug-вывод и подсветка исходников из оригинала
|
||||
* не портированы — устаревшие технологии, не нужны для этого проекта.
|
||||
*/
|
||||
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, иначе — пустую строку.
|
||||
*
|
||||
@ -34,19 +54,215 @@ class ProfilerToolbar
|
||||
|
||||
$time_ms = (microtime(true) - ($_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true))) * 1000;
|
||||
$memory = memory_get_peak_usage(true);
|
||||
// Захватываем ДО sqlTab(): EXPLAIN внутри неё сам логируется в Profiler (обычный query()
|
||||
// через тот же PdoDriver) — иначе шапка "SQL: N (X ms)" включила бы наши же debug-запросы.
|
||||
$query_count = Profiler::count();
|
||||
$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 = '';
|
||||
|
||||
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>' .
|
||||
'<td>' . self::explain($entry['sql'], $entry['params']) . '</td>' .
|
||||
'</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 $query_count Число SQL-запросов
|
||||
* @param float $query_time Суммарное время SQL-запросов, мс
|
||||
* @param string $rows Готовые <tr> со списком запросов
|
||||
* @param array<string,array{label:string,html:string}> $tabs Вкладки панели
|
||||
* @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);
|
||||
$time = number_format($time_ms, 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
|
||||
<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;">
|
||||
<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;"
|
||||
onclick="var b=document.getElementById('profiler-toolbar-body');b.style.display=b.style.display==='none'?'block':'none';">
|
||||
<strong style="color:#8bc34a;">Profiler</strong>
|
||||
@ -73,18 +316,19 @@ class ProfilerToolbar
|
||||
<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 id="profiler-toolbar-body" style="display:none;border-top:1px solid #444;">
|
||||
<div style="border-bottom:1px solid #333;">{$buttons}</div>
|
||||
<div style="max-height:300px;overflow:auto;">{$panels}</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>
|
||||
HTML;
|
||||
}
|
||||
|
||||
@ -97,6 +97,7 @@ abstract class Repository
|
||||
* @param callable $callback
|
||||
* @return mixed
|
||||
* @throws MyException
|
||||
* @throws Throwable
|
||||
*/
|
||||
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 string $order Колонка сортировки
|
||||
* @param string $sort ASC|DESC
|
||||
* @param int $fetch Режим PDO::FETCH_*
|
||||
* @param int $fetch Режим PDO::FETCH_*; для FETCH_CLASS (по умолчанию) строки — $this->obj_class
|
||||
* @return bool|T[]
|
||||
* @throws MyException
|
||||
*/
|
||||
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 {
|
||||
$str = '';
|
||||
|
||||
@ -189,7 +190,7 @@ abstract class Repository
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($where);
|
||||
|
||||
return $stmt->fetchAll($fetch);
|
||||
return $fetch === PDO::FETCH_CLASS ? $stmt->fetchAll($fetch, $this->obj_class) : $stmt->fetchAll($fetch);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -215,7 +216,7 @@ abstract class Repository
|
||||
|
||||
$stmt->execute();
|
||||
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -122,6 +122,18 @@ class PdoDriverTest extends TestCase
|
||||
$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
|
||||
{
|
||||
$this->assertFalse($this->driver->inTransaction());
|
||||
|
||||
@ -4,7 +4,6 @@ namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Services\DataBase\Classes\Profiler;
|
||||
use Services\DataBase\Classes\ProfilerPDO;
|
||||
|
||||
class ProfilerTest extends TestCase
|
||||
{
|
||||
@ -48,13 +47,4 @@ class ProfilerTest extends TestCase
|
||||
$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']);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,8 +5,10 @@ namespace Tests\Unit;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
use Services\Auth;
|
||||
use Services\Database;
|
||||
use Services\DataBase\Classes\Profiler;
|
||||
use System\Classes\Config;
|
||||
use System\Classes\MyException;
|
||||
use System\Classes\ProfilerToolbar;
|
||||
use System\Classes\Session;
|
||||
|
||||
@ -45,6 +47,16 @@ class ProfilerToolbarTest extends TestCase
|
||||
$prop = $ref->getProperty('config');
|
||||
$prop->setAccessible(true);
|
||||
$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
|
||||
@ -79,4 +91,74 @@ class ProfilerToolbarTest extends TestCase
|
||||
$this->assertStringContainsString('SELECT * FROM users WHERE id = ?', $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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -80,7 +80,7 @@ class RepositoryTest extends TestCase
|
||||
$this->repository->create(['name' => 'b', 'qty' => 2]);
|
||||
$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);
|
||||
}
|
||||
@ -129,7 +129,7 @@ class RepositoryTest extends TestCase
|
||||
|
||||
$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
|
||||
@ -143,7 +143,7 @@ class RepositoryTest extends TestCase
|
||||
} 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
|
||||
@ -154,4 +154,14 @@ class RepositoryTest extends TestCase
|
||||
$this->assertIsArray($rows[0]);
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user