This commit is contained in:
Egor Isaev 2026-08-07 13:44:45 +03:00
parent 9ff9cc54e6
commit 8fdcd0b901
12 changed files with 214 additions and 41 deletions

View File

@ -20,8 +20,6 @@ use System\Classes\MyException;
class IndexController extends Controller class IndexController extends Controller
{ {
/** /**
* Главная страница: GET-запрос к внешнему API, вывод todo в шаблон.
*
* @return string * @return string
* @throws MyException * @throws MyException
*/ */

View File

@ -9,7 +9,6 @@
namespace App\Repositories; namespace App\Repositories;
use PDO; use PDO;
use System\Classes\MyException;
use System\Classes\Repository; use System\Classes\Repository;
/** /**
@ -24,7 +23,6 @@ class BudgetRepository extends Repository
/** /**
* @return array * @return array
* @throws MyException
*/ */
public function getB(): array public function getB(): array
{ {

View File

@ -80,7 +80,7 @@ PhpStorm может показывать предупреждение «Namespac
|---|---| |---|---|
| `System\Classes\Core` | Bootstrap, обработка ошибок, поиск файлов (`findFile`), константы среды | | `System\Classes\Core` | Bootstrap, обработка ошибок, поиск файлов (`findFile`), константы среды |
| `System\Classes\BaseController` | Голое ядро (abstract); жизненный цикл `executeAction()`: `before()` → экшен → `after()` | | `System\Classes\BaseController` | Голое ядро (abstract); жизненный цикл `executeAction()`: `before()` → экшен → `after()` |
| `System\Classes\Controller` | Веб-контроллер (extends BaseController): `render()`/`renderContent()` + авто-CSRF в `before()` + `json()` | | `System\Classes\Controller` | Веб-контроллер (extends BaseController): `render()`/`renderContent()` + авто-CSRF в `before()` + `json()` + заголовок `X-Profiler` в `after()` для admin |
| `System\Classes\View` | Рендеринг `.html`-шаблонов через `ob_start` + `extract` + `include`; `setStyle()`/`setScript()`/`setManifest()`+`getStyles()`/`getScripts()` — подключение CSS/JS из шаблона | | `System\Classes\View` | Рендеринг `.html`-шаблонов через `ob_start` + `extract` + `include`; `setStyle()`/`setScript()`/`setManifest()`+`getStyles()`/`getScripts()` — подключение CSS/JS из шаблона |
| `System\Classes\ProfilerToolbar` | Debug-панель внизу страницы (аналог ProfilerToolbar для Kohana): время/память/SQL из `Profiler`; `render()` — пусто, если пользователь не admin | | `System\Classes\ProfilerToolbar` | Debug-панель внизу страницы (аналог ProfilerToolbar для Kohana): время/память/SQL из `Profiler`; `render()` — пусто, если пользователь не admin |
| `System\Classes\Route` | Автоматический роутинг: парсит URI, ищет контроллер в `App/Controller/` | | `System\Classes\Route` | Автоматический роутинг: парсит URI, ищет контроллер в `App/Controller/` |
@ -439,9 +439,8 @@ GeSHi) из оригинала не переносились — устарев
- **SQL** — из `Services\DataBase\Classes\Profiler` (см. раздел DataBase выше): время, текст запроса, параметры, - **SQL** — из `Services\DataBase\Classes\Profiler` (см. раздел DataBase выше): время, текст запроса, параметры,
`EXPLAIN` для SELECT-запросов (прогоняется заново через `Database::instance()` в момент рендера панели, `EXPLAIN` для SELECT-запросов (прогоняется заново через `Database::instance()` в момент рендера панели,
не при исполнении самого запроса; ошибка `EXPLAIN` → `—`, не ломает панель). Сам `EXPLAIN` — тоже не при исполнении самого запроса; ошибка `EXPLAIN` → `—`, не ломает панель). Сам `EXPLAIN` выполняется
обычный `query()`, поэтому попадает в `Profiler`, но счётчик/время в шапке панели ("SQL: N (X ms)") с `Statement::$skip_profiling = true` — иначе попал бы в тот же `Profiler` и засорял бы свой же счётчик.
захватываются до его выполнения — иначе панель считала бы свои же debug-запросы.
- **Vars** — `GET`/`POST`/`COOKIE`/`SESSION`/`SERVER`; чувствительные ключи (`Log::$mask_keys`: - **Vars** — `GET`/`POST`/`COOKIE`/`SESSION`/`SERVER`; чувствительные ключи (`Log::$mask_keys`:
`password`, `pass`, `csrf_token`, `token`) маскируются `***` — та же маска, что и в `Log`. `password`, `pass`, `csrf_token`, `token`) маскируются `***` — та же маска, что и в `Log`.
- **Files** — все подключённые к запросу файлы (`get_included_files()`) с размером и общим итогом. - **Files** — все подключённые к запросу файлы (`get_included_files()`) с размером и общим итогом.
@ -455,6 +454,33 @@ GeSHi) из оригинала не переносились — устарев
в проекте). Верхняя строка сворачивает/разворачивает панель, кнопки переключают вкладки — инлайн в проекте). Верхняя строка сворачивает/разворачивает панель, кнопки переключают вкладки — инлайн
`onclick` + один `<script>`/`<style>` внутри самого `render()`, без внешних файлов — панель самодостаточна. `onclick` + один `<script>`/`<style>` внутри самого `render()`, без внешних файлов — панель самодостаточна.
**Ajax/API-ответы панель не видят** — `renderContent()`/`json()` не проходят через `layout.html`, где она
подключена. Вместо HTML-панели `Controller::after()` (см. таблицу Core classes) для admin ставит заголовок
`X-Profiler` — JSON с полной картиной запроса (`time_ms`, `memory_mb`, `sql_count`, `sql_time_ms`, список
`sql` с временем каждого) — виден в devtools → Network → заголовки ответа для любого запроса, включая ajax;
тело ответа не трогается. `EXPLAIN` панели (см. выше) не участвует — `skip_profiling`.
**На странице ошибки панель тоже есть** — `System/view/exception/error.html` (см. `MyException::handler()`,
показывается только в DEVELOPMENT) заканчивается тем же `<?= \System\Classes\ProfilerToolbar::render() ?>`,
что и `layout.html` — видно, какие SQL-запросы успели выполниться до падения. Аналог `ProfilerToolbar::render(true)`
в конце `views/kohana/error.php` у оригинала; в отличие от оригинала — без своей подсветки исходников через
GeSHi и без разбора стека вручную, это уже даёт `$xdebug_message` (готовая таблица от Xdebug: файл/строка/
память/время по каждому кадру), если расширение включено.
**Панель полностью самодостаточна по стилям — общий сброс `#profiler-toolbar, #profiler-toolbar * {...}`**
(font-family/font-size/color/margin/padding и т.д. явно на каждом потомке), не полагаясь на наследование.
Понадобилось из-за двух независимых багов, которые проявлялись по-разному на разных страницах:
- на странице ошибки нет `<!DOCTYPE html>` (`error.html` рендерится как голый фрагмент, без layout) —
браузер в quirks mode, где `<table>` в некоторых движках не наследует `color` от предков → текст в
таблицах панели становился чёрным на тёмном фоне именно там;
- на обычных страницах (`layout.html`) подключён Bootstrap, который стилизует голые теги без класса —
`code {color:#d63384}` (розовый), `h4 {font-size:1.5rem}` (крупный) — эти правила побеждали
наследование от `#profiler-toolbar`, потому что *явное* правило на самом элементе всегда сильнее
унаследованного значения, независимо от специфичности правила у предка.
Розовый акцент для `<code>` (SQL/пути к файлам/ключи) — теперь свой, явный (`#profiler-toolbar code {color:#ff79c6}`),
не случайно унаследованный от Bootstrap — поэтому одинаково выглядит что на обычной странице, что на странице ошибки.
### Log (System\Classes\Log) ### Log (System\Classes\Log)
Файловый логгер в стиле `Config`/`Cookie`. Пишет в два файла, именованных датой: Файловый логгер в стиле `Config`/`Cookie`. Пишет в два файла, именованных датой:
@ -593,6 +619,7 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap
| `tests/Unit/ValidationTest.php` | правила, `matches`, пропуск пустых, first-error, label/плейсхолдеры, fluent | | `tests/Unit/ValidationTest.php` | правила, `matches`, пропуск пустых, first-error, label/плейсхолдеры, fluent |
| `tests/Unit/CSRFTest.php` | `token()` стабильность/формат, `validate()`, `field()`, нет токена в сессии | | `tests/Unit/CSRFTest.php` | `token()` стабильность/формат, `validate()`, `field()`, нет токена в сессии |
| `tests/Unit/ControllerTest.php` | `executeAction()`, порядок `before/action/after`, no-op хуки | | `tests/Unit/ControllerTest.php` | `executeAction()`, порядок `before/action/after`, no-op хуки |
| `tests/Unit/ControllerAfterTest.php` | `after()` веб-`Controller`: заголовок `X-Profiler` только для admin, содержит SQL-сводку — через `xdebug_get_headers()`, иначе `markTestSkipped()` |
| `tests/Unit/LogTest.php` | уровни/порог, каналы (action/error), формат, append, `strtr`, маскировка, `requestInfo()` | | `tests/Unit/LogTest.php` | уровни/порог, каналы (action/error), формат, append, `strtr`, маскировка, `requestInfo()` |
| `tests/Unit/FileLogReaderTest.php` | парсинг, фильтры (level/q/channel), newest-first, missing file, `dates()` | | `tests/Unit/FileLogReaderTest.php` | парсинг, фильтры (level/q/channel), newest-first, missing file, `dates()` |
| `tests/Unit/FileAuthDriverTest.php` | `login()` верно/неверно/неизвестный логин, `loggedIn()`, `getUser()` без пароля, `logout()`, `checkPassword()`, отсутствие файла | | `tests/Unit/FileAuthDriverTest.php` | `login()` верно/неверно/неизвестный логин, `loggedIn()`, `getUser()` без пароля, `logout()`, `checkPassword()`, отсутствие файла |
@ -609,7 +636,7 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap
| `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, вкладки Vars/Files/Route, маскировка чувствительных ключей, вкладка Custom только при `addData()`, `EXPLAIN` для реального SELECT / пропуск для не-SELECT | | `tests/Unit/ProfilerToolbarTest.php` | `render()` пусто для гостя/не-admin, панель с временем/памятью/SQL для admin, вкладки Vars/Files/Route, маскировка чувствительных ключей, вкладка Custom только при `addData()`, `EXPLAIN` для реального SELECT / пропуск для не-SELECT |
**242 теста, 379 assertion — все проходят (8 skipped: Elasticsearch/Mongo без живой инфраструктуры — MariaDB подключена и все её тесты реально проходят, см. разделы DataBase/Elasticsearch/Mongo выше).** **247 тестов, 384 assertion — все проходят (8 skipped: Elasticsearch/Mongo без живой инфраструктуры — MariaDB подключена и все её тесты реально проходят, см. разделы DataBase/Elasticsearch/Mongo выше).**
### Frontend dependencies (через Composer) ### Frontend dependencies (через Composer)

View File

@ -12,6 +12,7 @@ use PDO;
use PDOException; use PDOException;
use PDOStatement; use PDOStatement;
use System\Classes\MyException; use System\Classes\MyException;
use Throwable;
/** /**
* Обёртка над PDO: ленивое подключение (с перебором хостов при отказе — failover), * Обёртка над PDO: ленивое подключение (с перебором хостов при отказе — failover),
@ -37,7 +38,6 @@ class PdoDriver
* Не делает ничего, если подключение уже установлено. * Не делает ничего, если подключение уже установлено.
* *
* @return void * @return void
* @throws MyException Если не удалось подключиться ни к одному хосту
*/ */
public function connect(): void public function connect(): void
{ {
@ -78,7 +78,6 @@ class PdoDriver
/** /**
* @return PDO Подключается при первом обращении * @return PDO Подключается при первом обращении
* @throws MyException
*/ */
public function pdo(): PDO public function pdo(): PDO
{ {
@ -92,7 +91,6 @@ class PdoDriver
* *
* @param string $sql SQL-запрос * @param string $sql SQL-запрос
* @return false|PDOStatement * @return false|PDOStatement
* @throws MyException
*/ */
public function prepare(string $sql): false|PDOStatement public function prepare(string $sql): false|PDOStatement
{ {
@ -105,7 +103,6 @@ class PdoDriver
* @param string $sql SQL-запрос * @param string $sql SQL-запрос
* @param array $params Параметры для execute() * @param array $params Параметры для execute()
* @return false|PDOStatement * @return false|PDOStatement
* @throws MyException
*/ */
public function query(string $sql, array $params = []): false|PDOStatement public function query(string $sql, array $params = []): false|PDOStatement
{ {
@ -118,7 +115,6 @@ class PdoDriver
/** /**
* @param string $sql SQL-запрос * @param string $sql SQL-запрос
* @return int Число задетых строк * @return int Число задетых строк
* @throws MyException
*/ */
public function exec(string $sql): int public function exec(string $sql): int
{ {
@ -129,7 +125,6 @@ class PdoDriver
* Начинает транзакцию; на вложенном уровне — SAVEPOINT. * Начинает транзакцию; на вложенном уровне — SAVEPOINT.
* *
* @return bool * @return bool
* @throws MyException
*/ */
public function beginTransaction(): bool public function beginTransaction(): bool
{ {
@ -146,7 +141,6 @@ class PdoDriver
* Фиксирует транзакцию; на вложенном уровне — RELEASE SAVEPOINT. * Фиксирует транзакцию; на вложенном уровне — RELEASE SAVEPOINT.
* *
* @return bool * @return bool
* @throws MyException
*/ */
public function commit(): bool public function commit(): bool
{ {
@ -161,7 +155,6 @@ class PdoDriver
* Откатывает транзакцию; на вложенном уровне — ROLLBACK TO SAVEPOINT. * Откатывает транзакцию; на вложенном уровне — ROLLBACK TO SAVEPOINT.
* *
* @return bool * @return bool
* @throws MyException
*/ */
public function rollback(): bool public function rollback(): bool
{ {
@ -187,7 +180,7 @@ class PdoDriver
* *
* @param callable $callback * @param callable $callback
* @return mixed Результат $callback * @return mixed Результат $callback
* @throws MyException * @throws Throwable
*/ */
public function transaction(callable $callback): mixed public function transaction(callable $callback): mixed
{ {
@ -198,7 +191,7 @@ class PdoDriver
$this->commit(); $this->commit();
return $result; return $result;
} catch (\Throwable $e) { } catch (Throwable $e) {
$this->rollback(); $this->rollback();
throw $e; throw $e;
@ -207,7 +200,6 @@ class PdoDriver
/** /**
* @return string|false ID последней вставленной строки * @return string|false ID последней вставленной строки
* @throws MyException
*/ */
public function lastInsertId(): string|false public function lastInsertId(): string|false
{ {

View File

@ -25,6 +25,12 @@ class Statement extends PDOStatement
/** @var array<int|string,mixed> Значения, забинженные через bindValue()/execute() */ /** @var array<int|string,mixed> Значения, забинженные через bindValue()/execute() */
protected array $_bound_params = []; protected array $_bound_params = [];
/**
* @var bool Не логировать этот execute() в Profiler — для служебных запросов вроде EXPLAIN
* внутри самой панели (ProfilerToolbar), которые иначе засоряли бы её же счётчик SQL.
*/
public bool $skip_profiling = false;
/** /**
* Конструктор вызывается только изнутри PDO (PDO::ATTR_STATEMENT_CLASS) — не создавать напрямую. * Конструктор вызывается только изнутри PDO (PDO::ATTR_STATEMENT_CLASS) — не создавать напрямую.
* *
@ -56,7 +62,9 @@ class Statement extends PDOStatement
$start = microtime(true); $start = microtime(true);
$result = parent::execute($params); $result = parent::execute($params);
Profiler::log($this->queryString, $this->_bound_params, (microtime(true) - $start) * 1000); if (!$this->skip_profiling) {
Profiler::log($this->queryString, $this->_bound_params, (microtime(true) - $start) * 1000);
}
return $result; return $result;
} }

View File

@ -9,6 +9,7 @@
namespace System\Classes; namespace System\Classes;
use Services\Auth; use Services\Auth;
use Services\DataBase\Classes\Profiler;
use System\Classes\HTTP\HTTPException; use System\Classes\HTTP\HTTPException;
use System\Classes\HTTP\Request as HTTPRequest; use System\Classes\HTTP\Request as HTTPRequest;
@ -69,6 +70,42 @@ class Controller extends BaseController
} }
} }
/**
* Для admin — заголовок X-Profiler с полной картиной запроса (время, память, список
* SQL-запросов с временем каждого). Виден в devtools (Network → заголовки ответа) для
* любого ответа, включая ajax — там HTML-панель ProfilerToolbar не рендерится вовсе
* (renderContent()/json() не проходят через layout.html, где она подключена). Тело
* ответа не трогаем осознанно — ajax-эндпоинты отдают JSON/HTML, менять их форму ради
* дебага не нужно.
*
* @return void
* @throws \JsonException
*/
protected function after(): void
{
$user = Auth::instance()->getUser();
if (($user['role'] ?? null) !== 'admin' || headers_sent()) {
return;
}
$time_ms = (microtime(true) - ($_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true))) * 1000;
$data = [
'time_ms' => round($time_ms, 1),
'memory_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 1),
'sql_count' => Profiler::count(),
'sql_time_ms' => round(Profiler::totalTime(), 1),
'sql' => array_map(
static fn (array $entry) => ['sql' => $entry['sql'], 'time_ms' => round($entry['time_ms'], 2)],
Profiler::entries()
),
];
header('X-Profiler: ' . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
}
/** /**
* Рендерит шаблон контента внутри layout вместе с верхним и боковым меню. * Рендерит шаблон контента внутри layout вместе с верхним и боковым меню.
* *

View File

@ -8,14 +8,19 @@
namespace System\Classes; namespace System\Classes;
use Exception; use RuntimeException;
use Throwable; use Throwable;
/** /**
* Кастомное исключение с подстановкой переменных в сообщение (strtr). * Кастомное исключение с подстановкой переменных в сообщение (strtr).
* Зарегистрировано через set_exception_handler в Core::init(). * Зарегистрировано через set_exception_handler в Core::init().
*
* extends RuntimeException (не Exception) — PhpStorm по умолчанию считает RuntimeException
* unchecked-исключением (см. "Unchecked Exceptions" в Settings | PHP | Analysis, встроенный
* дефолт платформы, без какой-либо настройки проекта) и не требует @throws MyException
* на каждом методе по всей цепочке вызовов, как для обычного Exception.
*/ */
class MyException extends Exception class MyException extends RuntimeException
{ {
/** /**
* @param string $message Сообщение, может содержать плейсхолдеры для $variables * @param string $message Сообщение, может содержать плейсхолдеры для $variables

View File

@ -109,7 +109,11 @@ class ProfilerToolbar
} }
try { try {
$rows = Database::instance()->query('EXPLAIN ' . $sql, $params)->fetchAll(); // skip_profiling — иначе сам EXPLAIN попадёт в Profiler и засорит счётчик SQL панели.
$stmt = Database::instance()->prepare('EXPLAIN ' . $sql);
$stmt->skip_profiling = true;
$stmt->execute($params);
$rows = $stmt->fetchAll();
} catch (\Throwable) { } catch (\Throwable) {
return '—'; return '—';
} }
@ -298,15 +302,26 @@ class ProfilerToolbar
<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> <style>
/* Общий сброс — панель должна выглядеть одинаково независимо от того, что уже
загружено на странице-хосте (Bootstrap на обычных страницах красит code в розовый
и даёт h4 крупный шрифт; на error.html Bootstrap не подключён вовсе — там того
же текста, наоборот, не хватало цвета из-за quirks mode без <!DOCTYPE html>).
Задаём всё явно на каждом потомке, а не полагаемся на наследование. */
#profiler-toolbar, #profiler-toolbar * {
font-family:ui-monospace,"SF Mono","Cascadia Code",monospace;
font-size:12px;font-weight:400;line-height:1.4;color:#ddd;
margin:0;padding:0;text-align:left;background:none;border:0;box-sizing:border-box;
}
#profiler-toolbar code {color:#ff79c6;}
#profiler-toolbar .ptb-table {width:100%;border-collapse:collapse;} #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, #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-table th {color:#999;}
#profiler-toolbar .ptb-explain {font-size:11px;} #profiler-toolbar .ptb-explain {font-size:11px;}
#profiler-toolbar .ptb-explain td, #profiler-toolbar .ptb-explain th {padding:2px 6px;} #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-h4 {margin:8px 10px 2px;color:#8bc34a;font-weight:600;}
#profiler-toolbar .ptb-pre {margin:4px 10px;white-space:pre-wrap;word-break:break-all;} #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-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 {padding:6px 12px;cursor:pointer;border-bottom:2px solid transparent;}
#profiler-toolbar .ptb-tab-btn-active {border-bottom-color:#8bc34a;color:#8bc34a;} #profiler-toolbar .ptb-tab-btn-active {border-bottom-color:#8bc34a;color:#8bc34a;}
</style> </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;"

View File

@ -45,7 +45,6 @@ abstract class Repository
* @param string $table Таблица * @param string $table Таблица
* @param class-string<T> $obj_class Класс строки (fetchObject()); по умолчанию — лёгкий Model * @param class-string<T> $obj_class Класс строки (fetchObject()); по умолчанию — лёгкий Model
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию * @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
* @throws MyException
*/ */
public function __construct(string $table, string $obj_class = Model::class, ?string $connection = null) public function __construct(string $table, string $obj_class = Model::class, ?string $connection = null)
{ {
@ -61,7 +60,6 @@ abstract class Repository
* в одной внешней транзакции). * в одной внешней транзакции).
* *
* @return bool * @return bool
* @throws MyException
*/ */
public function beginTransaction(): bool public function beginTransaction(): bool
{ {
@ -72,7 +70,6 @@ abstract class Repository
* Фиксирует транзакцию; на вложенном уровне — RELEASE SAVEPOINT. * Фиксирует транзакцию; на вложенном уровне — RELEASE SAVEPOINT.
* *
* @return bool * @return bool
* @throws MyException
*/ */
public function commit(): bool public function commit(): bool
{ {
@ -83,7 +80,6 @@ abstract class Repository
* Откатывает транзакцию; на вложенном уровне — ROLLBACK TO SAVEPOINT. * Откатывает транзакцию; на вложенном уровне — ROLLBACK TO SAVEPOINT.
* *
* @return bool * @return bool
* @throws MyException
*/ */
public function rollBack(): bool public function rollBack(): bool
{ {
@ -96,7 +92,6 @@ abstract class Repository
* *
* @param callable $callback * @param callable $callback
* @return mixed * @return mixed
* @throws MyException
* @throws Throwable * @throws Throwable
*/ */
public function transaction(callable $callback): mixed public function transaction(callable $callback): mixed
@ -120,7 +115,6 @@ abstract class Repository
* *
* @param int|string $id * @param int|string $id
* @return T|false * @return T|false
* @throws MyException
*/ */
public function get(int|string $id): mixed public function get(int|string $id): mixed
{ {
@ -137,7 +131,6 @@ abstract class Repository
* *
* @param string $where SQL-условие после WHERE * @param string $where SQL-условие после WHERE
* @return T|false|null null, если $where пуст * @return T|false|null null, если $where пуст
* @throws MyException
*/ */
public function getItemWhere(string $where): mixed public function getItemWhere(string $where): mixed
{ {
@ -159,7 +152,6 @@ abstract class Repository
* @param string $sort ASC|DESC * @param string $sort ASC|DESC
* @param int $fetch Режим PDO::FETCH_*; для FETCH_CLASS (по умолчанию) строки — $this->obj_class * @param int $fetch Режим PDO::FETCH_*; для FETCH_CLASS (по умолчанию) строки — $this->obj_class
* @return bool|T[] * @return bool|T[]
* @throws MyException
*/ */
public function getList( public function getList(
string $cols, array $where = [], string $order = '', string $sort = 'ASC', int $fetch = PDO::FETCH_CLASS string $cols, array $where = [], string $order = '', string $sort = 'ASC', int $fetch = PDO::FETCH_CLASS
@ -198,7 +190,6 @@ abstract class Repository
* *
* @param array<string,mixed> $data * @param array<string,mixed> $data
* @return int lastInsertId * @return int lastInsertId
* @throws MyException
*/ */
public function create(array $data): int public function create(array $data): int
{ {
@ -224,7 +215,6 @@ abstract class Repository
* *
* @param array<string,mixed> $data [$primary_col обязателен] * @param array<string,mixed> $data [$primary_col обязателен]
* @return int Число задетых строк * @return int Число задетых строк
* @throws MyException
*/ */
public function update(array $data): int public function update(array $data): int
{ {
@ -254,7 +244,6 @@ abstract class Repository
* *
* @param int|string $id * @param int|string $id
* @return bool * @return bool
* @throws MyException
*/ */
public function delete(int|string $id): bool public function delete(int|string $id): bool
{ {
@ -271,7 +260,6 @@ abstract class Repository
* @param string $where SQL-условие после WHERE (с плейсхолдерами) * @param string $where SQL-условие после WHERE (с плейсхолдерами)
* @param array<int|string,mixed> $params Параметры для плейсхолдеров * @param array<int|string,mixed> $params Параметры для плейсхолдеров
* @return bool * @return bool
* @throws MyException
*/ */
public function deleteWhere(string $where, array $params): bool public function deleteWhere(string $where, array $params): bool
{ {

View File

@ -46,7 +46,7 @@ class View
* @param string|null $file Имя файла шаблона без расширения * @param string|null $file Имя файла шаблона без расширения
* @param string $dir Каталог шаблона относительно базовых путей * @param string $dir Каталог шаблона относительно базовых путей
* @param array|null $data Данные для шаблона * @param array|null $data Данные для шаблона
* @throws MyException
*/ */
public function __construct(?string $file = null, string $dir = '', ?array $data = null) public function __construct(?string $file = null, string $dir = '', ?array $data = null)
{ {
@ -66,9 +66,8 @@ class View
* @param string $file Имя файла шаблона без расширения * @param string $file Имя файла шаблона без расширения
* @param string $dir Каталог шаблона (обязателен) * @param string $dir Каталог шаблона (обязателен)
* @return View * @return View
* @throws MyException Если $dir пуст или файл не найден
*/ */
public function setFilename(string $file, string $dir = ''): View public function setFilename(string $file, string $dir = ''): self
{ {
if ($dir === '') { if ($dir === '') {
throw new MyException("Параметр dir не передан в View::setFilename для файла '$file'"); throw new MyException("Параметр dir не передан в View::setFilename для файла '$file'");

View File

@ -38,3 +38,5 @@ $error_id = uniqid('error', true);
</div> </div>
<table><?=$xdebug_message?></table> <table><?=$xdebug_message?></table>
<?= \System\Classes\ProfilerToolbar::render() ?>

View File

@ -0,0 +1,104 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Services\Auth;
use Services\DataBase\Classes\Profiler;
use System\Classes\Config;
use System\Classes\Controller;
use System\Classes\Session;
class AfterTestController extends Controller
{
protected bool $_csrf_protection = false;
public function pingAction(): string
{
return 'pong';
}
}
class ControllerAfterTest extends TestCase
{
private string $users_file;
protected function setUp(): void
{
if (!function_exists('xdebug_get_headers')) {
$this->markTestSkipped('Нужен xdebug_get_headers() для проверки заголовков ответа');
}
header_remove();
$this->users_file = sys_get_temp_dir() . '/bicycle_after_users_' . uniqid() . '.php';
file_put_contents($this->users_file, '<?php return ' . var_export([
'admin' => ['password' => password_hash('x', PASSWORD_DEFAULT), 'role' => 'admin'],
'bob' => ['password' => password_hash('x', PASSWORD_DEFAULT), 'role' => 'manager'],
], true) . ';');
Config::set('auth', ['driver' => 'file', 'users_file' => $this->users_file, 'session_key' => 'auth_user']);
Profiler::reset();
}
protected function tearDown(): void
{
header_remove();
unlink($this->users_file);
Session::instance()->destroy();
Profiler::reset();
foreach ([Session::class, Auth::class] as $class) {
$ref = new ReflectionClass($class);
$prop = $ref->getProperty($class === Session::class ? 'instances' : '_instances');
$prop->setAccessible(true);
$prop->setValue(null, []);
}
$ref = new ReflectionClass(Config::class);
$prop = $ref->getProperty('config');
$prop->setAccessible(true);
$prop->setValue(null, null);
}
public function testNoXProfilerHeaderForGuest(): void
{
(new AfterTestController())->executeAction('pingAction');
$headers = implode("\n", xdebug_get_headers());
$this->assertStringNotContainsString('X-Profiler:', $headers);
}
public function testNoXProfilerHeaderForNonAdmin(): void
{
Session::instance()->set('auth_user', 'bob');
(new AfterTestController())->executeAction('pingAction');
$headers = implode("\n", xdebug_get_headers());
$this->assertStringNotContainsString('X-Profiler:', $headers);
}
public function testXProfilerHeaderForAdminContainsSqlSummary(): void
{
Session::instance()->set('auth_user', 'admin');
Profiler::log('SELECT * FROM budget', [], 12.34);
(new AfterTestController())->executeAction('pingAction');
$header = null;
foreach (xdebug_get_headers() as $h) {
if (str_starts_with($h, 'X-Profiler:')) {
$header = $h;
}
}
$this->assertNotNull($header);
$data = json_decode(substr($header, strlen('X-Profiler: ')), true, 512, JSON_THROW_ON_ERROR);
$this->assertSame(1, $data['sql_count']);
$this->assertSame('SELECT * FROM budget', $data['sql'][0]['sql']);
}
}