682 lines
54 KiB
Markdown
682 lines
54 KiB
Markdown
# CLAUDE.md
|
||
|
||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||
|
||
## Project overview
|
||
|
||
**Bicycle** — самописный PHP MVC micro-framework (PHP >= 8.2). Название отражает идиому «изобрести велосипед».
|
||
|
||
## Commands
|
||
|
||
```bash
|
||
# Установка зависимостей
|
||
composer install
|
||
|
||
# Обновление автозагрузчика после добавления классов
|
||
composer dump-autoload
|
||
|
||
# Запуск всех тестов через Docker (контейнер уже должен быть запущен)
|
||
docker exec bicycle vendor/bin/phpunit
|
||
|
||
# Один файл / один тест
|
||
docker exec bicycle vendor/bin/phpunit tests/Unit/RouteTest.php
|
||
docker exec bicycle vendor/bin/phpunit --filter testControllerAndAction tests/Unit/RouteTest.php
|
||
```
|
||
|
||
Composer-скриптов нет — phpunit вызывается напрямую. Запускать тесты надо именно внутри контейнера: PHP-интерпретатор живёт в Docker, volume `/home/isaevea/http/Bicycle` → `/opt/project`.
|
||
|
||
Проект запускается как веб-приложение через Apache + PHP 8.2 в контейнере `bicycle`.
|
||
|
||
## Architecture
|
||
|
||
### Directory layout
|
||
|
||
```
|
||
index.php — точка входа; константы EXT, DOCROOT, APPPATH, SYSPATH
|
||
System/Classes/ — ядро фреймворка (namespace System\Classes\)
|
||
System/Classes/HTTP/ — HTTP-инфраструктура: интерфейсы, заголовки, исключения
|
||
System/Classes/HTTP/Client/ — исходящие HTTP-запросы (Curl, Request, Response)
|
||
System/Classes/HTTP/Exception/ — HTTPException_403/404
|
||
App/Controller/ — контроллеры приложения (namespace App\Controller\)
|
||
App/Repositories/ — конкретные репозитории (extends System\Classes\Repository), например BudgetRepository
|
||
App/config/ — конфиги (config.php; config.local.php — в .gitignore)
|
||
App/view/ — шаблоны приложения (.html файлы с PHP-кодом)
|
||
App/media/ — статические ресурсы (js, css, img)
|
||
Services/Auth/ — авторизация: интерфейс AuthDriver + FileAuthDriver (см. раздел Auth ниже)
|
||
Services/Database.php, DataBase/ — реляционная БД (MariaDB/PDO): фабрика + Model/Classes/{PdoConnection,Statement,Profiler} (см. раздел DataBase ниже)
|
||
Services/Elasticsearch.php, Elasticsearch/ — поиск: фабрика + Client (REST, см. раздел Elasticsearch ниже)
|
||
Services/Mongo.php, Mongo/ — документная БД: фабрика + MongoDriver (см. раздел Mongo ниже)
|
||
Services/Mail/, PDF/ — пустые каталоги-заготовки под будущие сервисы (PHPMailer / dompdf); кода пока нет
|
||
System/view/ — системные шаблоны (ошибки 404/500/403, exception)
|
||
tools/sort_html_attrs.php — CLI-утилита сортировки HTML-атрибутов (gitignored: /tools/* в .gitignore)
|
||
tests/ — PHPUnit тесты
|
||
```
|
||
|
||
> Часть `Services/*` (Mail, PDF) ещё не созданы. `App/Classes/` — уже не пуст: `App\Classes\Currency`
|
||
> (`const LIST` — код ISO 4217 => название, сейчас RUB/USD/EUR/CNY), используется и в форме счёта
|
||
> (`App/view/Index/index.html`, `<select>`), и в валидации `App\Controller\AccountsController` —
|
||
> единственное место, которое нужно поправить, чтобы добавить валюту.
|
||
|
||
### Autoload (composer.json)
|
||
|
||
PSR-4 с относительными путями:
|
||
- `System\` → `System/`
|
||
- `App\` → `App/`
|
||
- `Services\` → `Services/`
|
||
- `Tests\` → `tests/` (dev)
|
||
|
||
PhpStorm может показывать предупреждение «Namespace doesn't match PSR-4» — это ложное срабатывание. Отключается в `Settings → Editor → Inspections → PHP → General`.
|
||
|
||
### Namespaces
|
||
|
||
- `System/Classes/*.php` → `namespace System\Classes;`
|
||
- `System/Classes/HTTP/*.php` → `namespace System\Classes\HTTP;`
|
||
- `System/Classes/HTTP/Client/*.php` → `namespace System\Classes\HTTP\Client;`
|
||
- `System/Classes/HTTP/Exception/*.php` → `namespace System\Classes\HTTP\Exception;`
|
||
- `App/Controller/*.php` → `namespace App\Controller;`
|
||
- `App/Classes/*.php` → `namespace App\Classes;`
|
||
- `Services/**/*.php` → `namespace Services\...;`
|
||
|
||
### Core classes
|
||
|
||
| Класс | Роль |
|
||
|---|---|
|
||
| `System\Classes\Core` | Bootstrap, обработка ошибок, поиск файлов (`findFile`), константы среды |
|
||
| `System\Classes\BaseController` | Голое ядро (abstract); жизненный цикл `executeAction()`: `before()` → экшен → `after()` |
|
||
| `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\ProfilerToolbar` | Debug-панель внизу страницы (аналог ProfilerToolbar для Kohana): время/память/SQL из `Profiler`; `render()` — пусто, если пользователь не admin |
|
||
| `System\Classes\Route` | Автоматический роутинг: парсит URI, ищет контроллер в `App/Controller/` |
|
||
| `System\Classes\Request` | Входящий HTTP-запрос; реализует `HTTP\Request`; `post()`, `query()`, `body()`, `isAjax()` |
|
||
| `System\Classes\Response` | HTTP-ответ; `body()` — геттер/сеттер содержимого |
|
||
| `System\Classes\MyException` | Кастомное исключение; зарегистрировано через `set_exception_handler` |
|
||
| `System\Classes\Cookie` | Статический хелпер для работы с куками: `get()`, `set()`, `delete()` |
|
||
| `System\Classes\Session` | Синглтон сессии: `instance(string $name)`, `get/set/delete/destroy/regenerate` |
|
||
| `System\Classes\Config` | Статический конфиг: lazy-load `App/config/config.php` + merge `config.local.php` |
|
||
| `System\Classes\Log` | Файловый логгер: уровни, аудит запросов, лог ошибок; `info()/error()`, маскировка |
|
||
| `System\Classes\LogReader` | Интерфейс чтения логов (`read()`, `dates()`); абстракция над хранилищем |
|
||
| `System\Classes\FileLogReader` | Чтение/парсинг логов из файлов; реализация `LogReader` (позже возможен `DbLogReader`) |
|
||
| `System\Classes\Validation` | Валидация входных данных по декларативным правилам; `factory()`, `rule()`, `check()`, `errors()` |
|
||
| `System\Classes\CSRF` | Защита форм от CSRF; токен в сессии: `token()`, `validate()`, `field()` |
|
||
| `System\Classes\HTTP` | `redirect()`, `requestHeaders()` |
|
||
| `System\Classes\HTTP\Header` | Extends `ArrayObject`; `send()`, `__toString()` → RFC-формат |
|
||
| `System\Classes\HTTP\Message` | Интерфейс: `protocol()`, `headers()`, `body()`, `render()` |
|
||
| `System\Classes\HTTP\Request` | Интерфейс входящего запроса; константы методов GET/POST/PUT/... |
|
||
| `System\Classes\HTTP\HTTPException` | HTTP-исключение для действительно исключительных кодов (throw + `MyException::handler()`); `factory(int $code)`, `getResponse(): Response`. Редиректы (302) через неё не делаются — не исключение, а штатный поток, см. `HTTP::redirect()` |
|
||
| `System\Classes\HTTP\Exception\HTTPException_403` | 403 Forbidden |
|
||
| `System\Classes\HTTP\Exception\HTTPException_404` | 404 Not Found |
|
||
| `System\Classes\HTTP\Client\Curl` | Исполнитель исходящих HTTP-запросов через cURL |
|
||
| `System\Classes\HTTP\Client\Request` | Билдер исходящего запроса: `method()`, `header()`, `json()`, `timeout()` |
|
||
| `System\Classes\HTTP\Client\Response` | Ответ внешнего запроса: `status()`, `body()`, `json()`, `isSuccess()` |
|
||
| `Services\Auth` | Точка входа в авторизацию: `instance(?string $driver = null)`, кэш по драйверу |
|
||
| `Services\Auth\AuthDriver` | Интерфейс: `login()`, `logout()`, `loggedIn()`, `getUser()`, `checkPassword()` |
|
||
| `Services\Auth\FileAuthDriver` | Авторизация по файлу пользователей (по умолчанию); позже — БД/LDAP/Keycloak |
|
||
| `Services\Database` | Точка входа в реляционную БД (MariaDB): `instance(?string $name = null): PdoConnection`, кэш по имени подключения |
|
||
| `Services\DataBase\Classes\PdoConnection` | Обёртка над `PDO`: `query()`/`prepare()`/`exec()`, диалект `mysql`\|`pgsql`\|`sqlite` через конфиг `dialect` (по умолчанию `mysql`), failover по `hosts` (кроме sqlite — там нет хостов), транзакции с вложенными SAVEPOINT (`transaction()`) |
|
||
| `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\Model` | `#[AllowDynamicProperties]`, лёгкий пассивный носитель данных строки с настоящими (не спрятанными) свойствами — под `PDO::FETCH_CLASS`; `toArray()` |
|
||
| `System\Classes\Repository` | `@template T`, abstract CRUD поверх таблицы: `get()/getItemWhere()/getList()/create()/update()/delete()/deleteWhere()`, `beginTransaction()/commit()/rollBack()/transaction()` (делегируют в `PdoConnection`); конкретные — в `App\Repositories\*` |
|
||
| `Services\Elasticsearch` | Точка входа в Elasticsearch: `instance(?string $name = null): Client`, кэш по имени подключения |
|
||
| `Services\Elasticsearch\Client` | REST-обёртка на `HTTP\Client\Curl` (без composer-зависимости `elasticsearch/elasticsearch`): `index()/get()/search()/delete()/exists()/createIndex()/deleteIndex()/ping()` |
|
||
| `Services\Mongo` | Точка входа в MongoDB: `instance(?string $name = null): MongoDriver`, кэш по имени подключения |
|
||
| `Services\Mongo\MongoDriver` | Обёртка над `MongoDB\Client` (пакет `mongodb/mongodb`, требует `ext-mongodb`): `client()`, `database()`, `collection()` |
|
||
|
||
### Request lifecycle
|
||
|
||
```
|
||
index.php
|
||
→ Core::init()
|
||
→ Request::factory() # detectUri(), Route::resolve(), заполняет post/query/body/method
|
||
→ Request::execute() # require_once контроллера, вызывает executeAction({action}Action)
|
||
→ Response->body() # строка с HTML
|
||
→ echo
|
||
```
|
||
|
||
### Internal Request (System\Classes\Request)
|
||
|
||
Входящий запрос от браузера. Реализует `System\Classes\HTTP\Request`.
|
||
|
||
- `factory()` — создаёт экземпляр, заполняет `$_GET`, `$_POST`, метод, тело, `X-Requested-With`
|
||
- `$initial` — первый запрос; `$current` — текущий
|
||
- `post($key?, $value?)` — getter/setter POST-данных
|
||
- `query($key?, $value?)` — getter/setter GET-параметров
|
||
- `body($content?)` — getter/setter тела запроса
|
||
- `method($method?)` — getter/setter HTTP-метода
|
||
- `isAjax()` — проверяет `X-Requested-With: XMLHttpRequest`
|
||
- `detectUri()` — определяет URI через `PATH_INFO` → `REQUEST_URI` → `PHP_SELF`
|
||
|
||
### External HTTP Client (System\Classes\HTTP\Client\*)
|
||
|
||
Исходящие запросы к внешним API.
|
||
|
||
```php
|
||
use System\Classes\HTTP\Client\{Request as HttpRequest, Curl};
|
||
|
||
$response = (new Curl())->execute(
|
||
HttpRequest::factory('https://api.example.com/data')
|
||
->method('POST')
|
||
->json(['key' => 'value'])
|
||
->timeout(10)
|
||
);
|
||
|
||
if ($response->isSuccess()) {
|
||
$data = $response->json();
|
||
}
|
||
```
|
||
|
||
### Session (System\Classes\Session)
|
||
|
||
Синглтон, поддерживает именованные сессии.
|
||
|
||
```php
|
||
use System\Classes\Session;
|
||
|
||
$session = Session::instance(); // сессия 'main'
|
||
$session->set('user_id', 42);
|
||
$id = $session->get('user_id');
|
||
$session->delete('user_id');
|
||
$session->regenerate(); // новый session_id, старые данные сохраняются
|
||
$session->destroy(); // уничтожить сессию
|
||
```
|
||
|
||
### Cookie (System\Classes\Cookie)
|
||
|
||
Статический хелпер.
|
||
|
||
```php
|
||
use System\Classes\Cookie;
|
||
|
||
Cookie::set('token', 'abc123', time() + 3600);
|
||
$val = Cookie::get('token');
|
||
Cookie::delete('token');
|
||
```
|
||
|
||
Свойства по умолчанию: `$expiration=0`, `$path='/'`, `$domain=null`, `$secure=false`, `$httponly=true`, `$samesite='Lax'`.
|
||
|
||
### Config (System\Classes\Config)
|
||
|
||
Lazy-load конфига из `App/config/config.php`. Если существует `App/config/config.local.php` — глубоко мержится поверх базового.
|
||
|
||
```php
|
||
use System\Classes\Config;
|
||
|
||
$db = Config::get('db'); // весь раздел
|
||
$params = Config::get('session', 'cookie_params'); // вложенный ключ
|
||
Config::set('foo', ['bar' => 'baz']); // переопределить в runtime
|
||
```
|
||
|
||
`config.local.php` исключён из git (`.gitignore`) — используется для переопределений на конкретном хосте.
|
||
|
||
### Validation (System\Classes\Validation)
|
||
|
||
Валидация входных данных (обычно `Request::post()`/`query()`) по декларативным правилам.
|
||
|
||
```php
|
||
use System\Classes\Validation;
|
||
|
||
$validation = Validation::factory($request->post())
|
||
->label('email', 'E-mail')
|
||
->rule('email', 'required')
|
||
->rule('email', 'email')
|
||
->rule('password', 'min_length', [8])
|
||
->rule('password_confirm', 'matches', ['password']);
|
||
|
||
if ($validation->check()) {
|
||
// данные валидны
|
||
} else {
|
||
$errors = $validation->errors(); // [поле => сообщение]
|
||
$one = $validation->error('email'); // сообщение одного поля или null
|
||
}
|
||
```
|
||
|
||
Правила: `required`, `email`, `url`, `numeric`, `digit`, `min_length`, `max_length`, `exact_length`, `matches`, `in`, `regex`. Правила одного поля проверяются по порядку до первой ошибки. Пустое необязательное поле (нет `required`) остальные правила пропускает. Сообщения — шаблоны с плейсхолдерами `:field`/`:param1`/`:param2` (подстановка через `strtr`, как в `MyException`).
|
||
|
||
### CSRF (System\Classes\CSRF)
|
||
|
||
Защита форм от CSRF. Токен хранится в сессии (`Session`), встраивается в форму скрытым полем и проверяется при обработке POST.
|
||
|
||
**Контроллеры — наследники `Controller` — проверяют токен автоматически** в `before()` на методах `POST/PUT/PATCH/DELETE` (отключается флагом `$_csrf_protection = false`, например для API/вебхуков). В шаблоне достаточно вставить поле:
|
||
|
||
```php
|
||
use System\Classes\CSRF;
|
||
|
||
<form method="post">
|
||
<?= CSRF::field() ?>
|
||
...
|
||
</form>
|
||
```
|
||
|
||
Ручная проверка (если авто-CSRF отключён или нужен свой цикл) — через `CSRF::validate()`:
|
||
|
||
```php
|
||
use System\Classes\HTTP\HTTPException;
|
||
|
||
if (!CSRF::validate(Request::$current->post(CSRF::$key))) {
|
||
throw HTTPException::factory(403);
|
||
}
|
||
```
|
||
|
||
> Контроллер создаётся в `Request::execute()` как `new $class()` без передачи запроса — доступ к данным через статический `Request::$current`, не через `$this->request`. Рабочий пример формы (валидация поверх авто-CSRF): `App/Controller/FeedbackController.php`.
|
||
|
||
- `token()` — токен текущей сессии (создаётся при первом обращении, `random_bytes(32)`).
|
||
- `validate($value)` — сравнение с сессией через `hash_equals()` (устойчиво к timing-атакам).
|
||
- `field()` — готовый `<input type="hidden">` с токеном.
|
||
- `CSRF::$key` — имя поля/ключа (по умолчанию `csrf_token`).
|
||
|
||
### Auth (Services\Auth)
|
||
|
||
Авторизация через сменный драйвер (`Services\Auth\AuthDriver`), как в Kohana Auth: по умолчанию
|
||
`FileAuthDriver` (логины/пароли из PHP-файла), позже — БД/LDAP/Keycloak как новые классы
|
||
`implements AuthDriver` без изменения остального кода (тот же принцип, что и `LogReader`/`FileLogReader`).
|
||
|
||
- `Services\Auth::instance(?string $name = null)` — драйвер по имени (кэшируется); без аргумента —
|
||
драйвер из `Config::get('auth', 'driver')` (по умолчанию `'file'`).
|
||
- `AuthDriver::login($login, $password)` — проверяет и, если верно, авторизует (пишет в `Session`).
|
||
- `AuthDriver::logout()` / `loggedIn()` / `getUser()` (без пароля) / `checkPassword($password)`
|
||
(сверка пароля с текущим авторизованным пользователем, например перед сменой настроек).
|
||
|
||
**Файл пользователей** — `App/config/auth_users.php` (в `.gitignore`, как `config.local.php`):
|
||
```php
|
||
return [
|
||
'admin' => [
|
||
'password' => '<password_hash(...)>',
|
||
'full_name' => 'ФИО',
|
||
'email' => '...',
|
||
'role' => 'admin', // 'admin' | 'manager' | 'user' | ... — своё для каждого проекта
|
||
],
|
||
];
|
||
```
|
||
Путь берётся из `Config::get('auth', 'users_file')`. Поля, кроме `password`, произвольные —
|
||
`AuthDriver::getUser()` отдаёт их как есть (без `password`); `role` — единственное поле, которое
|
||
понимает framework-код (`Controller::$_auth_roles`), остальное (`full_name`, `email`, …) — просто
|
||
проброс для шаблонов/логов.
|
||
|
||
**Контроллеры** включают проверку флагом `$_auth_protection = true` (по умолчанию `false`,
|
||
как `$_csrf_protection`, но с обратной полярностью — авторизация не обязательна по умолчанию).
|
||
`$_auth_driver` — имя конкретного драйвера, если контроллеру нужен не дефолтный (`null` = дефолт
|
||
из конфига). `$_auth_roles` — список разрешённых ролей (`['admin']`, `['admin', 'manager']`);
|
||
пустой массив (по умолчанию) — любой авторизованный, без проверки роли.
|
||
|
||
Проверка — в `Controller::before()`: нет авторизации → `HTTP::redirect('/login')` напрямую (не через
|
||
`HTTPException` — редирект гостя на логин не исключительная ситуация, а штатное поведение; раньше это
|
||
делалось через несуществующий больше `HTTPException_302`, но `throw` этого исключения ушёл бы в
|
||
`MyException::handler()`, который не вызывает `getResponse()` и настоящий редирект не отправил бы —
|
||
сам класс требовал прямого вызова `->getResponse()` без throw, что нарушало контракт остальных
|
||
подклассов `HTTPException` и вводило в заблуждение; убран целиком); авторизован, но роль не подходит →
|
||
`throw HTTPException::factory(403)`.
|
||
`App\Controller\Admin\AdminController` включает `$_auth_protection = true` и `$_auth_roles = ['admin']`
|
||
для всей админки.
|
||
|
||
**Вход/выход** — `App/Controller/LoginController.php` (`/login` — форма и обработка,
|
||
`/login/logout`), шаблон `App/view/Login/login.html`. Меню сайта (`Controller::menu()`) показывает
|
||
«Войти» либо «Выйти (логин)» в зависимости от `Auth::instance()->getUser()`; пункт «Админка» виден
|
||
только при `role === 'admin'`.
|
||
|
||
### DataBase (Services\Database, System\Classes\Repository)
|
||
|
||
Реляционная БД (MariaDB) через PDO. Конфиг — `Config::get('db', $name)`, ключ `$name` — это имя
|
||
*подключения*, по умолчанию `'default'`; значение — параметры подключения плоским массивом
|
||
(`host`/`hosts`/`port`/`user`/`password`/`dbname`/`dialect`/`charset`), без обёртки под тип
|
||
драйвера — PDO не «драйвер» в смысле выбора между несколькими реализациями (это сама обёртка над
|
||
клиентской библиотекой конкретной СУБД), других вариантов подключения к реляционной БД в проекте
|
||
нет, поэтому и нечего выбирать конфигом:
|
||
|
||
```php
|
||
use Services\Database;
|
||
|
||
$connection = Database::instance(); // Config::get('db', 'default')
|
||
$stmt = $connection->query('SELECT * FROM users WHERE id = ?', [42]);
|
||
$row = $stmt->fetch();
|
||
|
||
echo $stmt->sq(); // SQL с подставленными параметрами — для дебага (Statement::showQuery())
|
||
```
|
||
|
||
Подключение — ленивое (первое обращение к `pdo()`/`query()`), с failover: `hosts`
|
||
(массив) перебирается по порядку до первого успешного, иначе — `MyException` со списком ошибок
|
||
по каждому хосту.
|
||
|
||
**Диалект** — `dialect` (`'mysql'` по умолчанию, либо `'pgsql'`/`'sqlite'`) переключает
|
||
только сборку DSN внутри `PdoConnection::buildDsn()` — реестра классов-драйверов по типу СУБД нет
|
||
(в отличие от `Auth`/`LogReader`), потому что весь остальной код (`Statement`, `Profiler`,
|
||
SAVEPOINT-транзакции) от диалекта не зависит, это осталось бы дублированием ради дублирования.
|
||
`sqlite` — особый случай: `dbname` там путь к файлу (или `':memory:'`), а не имя базы на хосте,
|
||
поэтому у него нет `host`/`hosts`/`user`/`password`/`charset` и failover-перебор для него не
|
||
запускается (`connect()` подключается напрямую). Расширения `pdo_mysql` и `pdo_sqlite` есть в
|
||
текущем Docker-образе, `pdo_pgsql` — нет (диалект `pgsql` в коде поддержан, но не проверен вживую).
|
||
|
||
Транзакции на уровне `PdoConnection` — вложенные через SAVEPOINT
|
||
(`beginTransaction()`/`commit()`/`rollback()` считают уровень вложенности сами), либо через обёртку:
|
||
|
||
```php
|
||
$connection->transaction(function ($connection) {
|
||
$connection->query('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, 1]);
|
||
$connection->query('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, 2]);
|
||
});
|
||
// исключение внутри колбэка → rollback (или ROLLBACK TO SAVEPOINT на вложенном уровне) + повторный throw
|
||
```
|
||
|
||
Тайминги запросов — `Services\DataBase\Classes\Profiler::entries()`/`totalTime()` (наполняется
|
||
автоматически из `Statement::execute()` — единой точки для любого выполнения запроса, в т.ч.
|
||
ручного `$connection->prepare()->execute()` в обход `query()`, как делает большинство методов `Repository`).
|
||
|
||
**Repository** — `System\Classes\Repository` (`@template T of object`, abstract CRUD), конкретные — в
|
||
`App/Repositories/*`. Таблица и класс строки задаются через конструктор, не через переопределение
|
||
свойства:
|
||
|
||
```php
|
||
namespace App\Repositories;
|
||
use System\Classes\Repository;
|
||
|
||
class BudgetRepository extends Repository
|
||
{
|
||
public function __construct(?string $connection = null)
|
||
{
|
||
parent::__construct('budget', connection: $connection); // + class-string<T> вторым аргументом, по умолчанию Model::class
|
||
}
|
||
}
|
||
```
|
||
|
||
```php
|
||
$repo = new BudgetRepository(); // подключение — Database::instance()
|
||
$repo->get(1); // T|false, через fetchObject()
|
||
$repo->getItemWhere("status = 'open'"); // T|false|null, произвольное условие (без плейсхолдеров)
|
||
$repo->getList('*', ['status' => 'open']); // T[]|false; $where — 'col' => 'value' (или 'IS NULL'/'IS NOT NULL')
|
||
$repo->create(['title' => 'Q3', 'amount' => 1000]); // lastInsertId
|
||
$repo->update(['id' => 1, 'amount' => 1200]); // primary_col обязателен в $data
|
||
$repo->delete(1);
|
||
$repo->deleteWhere('status = ?', ['closed']);
|
||
$repo->transaction(fn () => /* несколько операций одной транзакцией */ null); // begin/commit/rollBack — делегируют в PdoConnection (там же и SAVEPOINT-логика)
|
||
```
|
||
|
||
`public string $primary_col = 'id'` и `public bool $is_auto_increment = true` — переопределяются в
|
||
наследнике при необходимости. `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 транзакции делегируются в `PdoConnection`, у которого счётчик один на всё подключение.
|
||
|
||
`Model` (`Services\DataBase\Model`, `#[AllowDynamicProperties]`) — лёгкий пассивный носитель данных,
|
||
это класс по умолчанию для `T` в `Repository`. Свойства настоящие (не спрятаны за внутренним массивом) —
|
||
это принципиально: `PDO::FETCH_CLASS`/`fetchObject()` выставляет их напрямую, **минуя `__set()`**
|
||
(внутренний механизм PDO, до вызова конструктора), поэтому попытка перехватить запись через `__set()`
|
||
в скрытый массив не сработает — `toArray()` вернул бы пусто. Не ActiveRecord — чтение/запись в БД
|
||
делает `Repository`.
|
||
|
||
### Elasticsearch (Services\Elasticsearch)
|
||
|
||
Тонкая REST-обёртка на `System\Classes\HTTP\Client\Curl` — без тяжёлой composer-зависимости
|
||
`elasticsearch/elasticsearch`. Конфиг — `Config::get('elasticsearch', $name)` (`base_url`, `timeout`,
|
||
опционально `username`/`password` для Basic Auth).
|
||
|
||
```php
|
||
use Services\Elasticsearch;
|
||
|
||
$client = Elasticsearch::instance();
|
||
$client->index('logs', ['level' => 'error', 'message' => 'boom'], 'log-1');
|
||
$client->get('logs', 'log-1');
|
||
$client->search('logs', ['query' => ['match' => ['message' => 'boom']]]);
|
||
$client->delete('logs', 'log-1');
|
||
$client->ping(); // false вместо исключения, если кластер недоступен — удобно для graceful skip
|
||
```
|
||
|
||
Контейнер `elasticsearch` в текущей dev-инфраструктуре не поднят (см. `docker-dev/docker-compose.yml`) —
|
||
`ElasticsearchClientTest` пропускает тесты через `markTestSkipped()`, если `ping()` вернул `false`;
|
||
оживает сам, без правки кода, как только контейнер появится.
|
||
|
||
### Mongo (Services\Mongo)
|
||
|
||
Обёртка над `MongoDB\Client` (пакет `mongodb/mongodb`, уже в `composer.json`). **Требует PHP-расширение
|
||
`ext-mongodb`**, которого пока нет в `docker/php-apache/8.2.8/Dockerfile` (общий для нескольких проектов —
|
||
трогать его нельзя, см. раздел Apache; расширение добавляет пользователь сам). Код безопасен без
|
||
расширения — падает только при реальном `new MongoDB\Client()` внутри `MongoDriver::__construct()`,
|
||
не при простом подключении файла/автозагрузке, поэтому `Mongo::instance()` не вызывать эагерно из
|
||
bootstrap. Конфиг — `Config::get('mongo', $name)` (`uri`, `database`):
|
||
|
||
```php
|
||
use Services\Mongo;
|
||
|
||
$driver = Mongo::instance();
|
||
$driver->collection('logs')->insertOne(['level' => 'error']);
|
||
$driver->database()->listCollections();
|
||
```
|
||
|
||
`MongoDriverTest` пропускает тесты через `markTestSkipped()`, если `!extension_loaded('mongodb')` —
|
||
оживает сам, без правки кода, как только пользователь добавит расширение и поднимет контейнер `mongo`.
|
||
|
||
### ProfilerToolbar (System\Classes\ProfilerToolbar)
|
||
|
||
Debug-панель внизу страницы (аналог [ProfilerToolbar для Kohana](https://github.com/Alert/profilertoolbar)) —
|
||
портирована не один-в-один: FireBug-вывод (`firebug()`/FirePHP) и подсветка исходников (`debugSource()`/
|
||
GeSHi) из оригинала не переносились — устаревшие технологии, не нужны здесь. Вкладки:
|
||
|
||
- **SQL** — из `Services\DataBase\Classes\Profiler` (см. раздел DataBase выше): время, текст запроса, параметры,
|
||
`EXPLAIN` для SELECT-запросов (прогоняется заново через `Database::instance()` в момент рендера панели,
|
||
не при исполнении самого запроса; ошибка `EXPLAIN` → `—`, не ломает панель). Сам `EXPLAIN` выполняется
|
||
с `Statement::$skip_profiling = true` — иначе попал бы в тот же `Profiler` и засорял бы свой же счётчик.
|
||
- **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()`, без внешних файлов — панель самодостаточна.
|
||
|
||
**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)
|
||
|
||
Файловый логгер в стиле `Config`/`Cookie`. Пишет в два файла, именованных датой:
|
||
`App/logs/action-{Y-m-d}.log` (действия пользователя) и `App/logs/error-{Y-m-d}.log`
|
||
(ошибки и предупреждения). Канал выбирается по уровню.
|
||
|
||
```php
|
||
use System\Classes\Log;
|
||
|
||
Log::info('Пользователь :id вошёл', [':id' => 42]); // подстановка :key через strtr
|
||
Log::error('Сбой оплаты');
|
||
Log::debug(...); Log::warning(...);
|
||
```
|
||
|
||
- Вкл/выкл целиком — `Log::$enabled` → `Config::get('log','enabled')` → `true` по умолчанию;
|
||
`false` — `write()` ничего не пишет ни в один файл, независимо от уровня/порога.
|
||
- Уровни: `debug(100) < info(200) < warning(300) < error(400)`. Пишутся только уровни не ниже
|
||
порога `Log::$threshold` (по умолчанию из `Config::get('log','threshold')` → `debug`).
|
||
- Канал по уровню: `info`/`debug` → файл `action-…`, `warning`/`error` → файл `error-…`.
|
||
- Каталог — `Log::$directory` → `Config::get('log','path')` → `APPPATH/logs`. Создаётся на лету.
|
||
- `Log::requestInfo()` — строка контекста текущего запроса (`METHOD /uri Controller::action
|
||
user=login params=… query=… post=…`) из `Request::$current`; `user` — логин из `Auth::provider()->user()`
|
||
(`'guest'`, если не авторизован); чувствительные ключи (`Log::$mask_keys`:
|
||
`password`, `pass`, `csrf_token`, `token`) маскируются `***`.
|
||
|
||
**Интеграция (автоматически):**
|
||
- `Request::execute()` пишет INFO на **каждый** входящий запрос (аудит — кто что делал).
|
||
- `MyException::handler()` пишет ERROR на каждое неперехваченное исключение (текст + контекст
|
||
запроса), и в DEVELOPMENT, и в PRODUCTION.
|
||
|
||
Каталог логов в `.gitignore` (`/App/logs/`).
|
||
|
||
**Просмотр логов:** страница `/admin/logs` (`App\Controller\Admin\LogsController`) с фильтрами
|
||
(дата, канал, уровень, текст). Источник — через интерфейс `LogReader` (реализация
|
||
`FileLogReader` парсит файлы; позже подменяется на `DbLogReader`). Каталог берётся из
|
||
`Log::directory()`. Вывод сообщений в шаблоне `App/view/Admin/Logs/logs.html` экранируется
|
||
(`htmlspecialchars`) — в логах есть пользовательский ввод (XSS-вектор).
|
||
|
||
### HTTPException (System\Classes\HTTP\HTTPException)
|
||
|
||
```php
|
||
use System\Classes\HTTP\HTTPException;
|
||
|
||
throw HTTPException::factory(404, 'Страница :url не найдена', [':url' => $uri]);
|
||
// или через getResponse() без throw:
|
||
$response = HTTPException::factory(403)->getResponse();
|
||
```
|
||
|
||
`factory(int $code)` создаёт `HTTPException_403/404` или базовый класс для неизвестных кодов. Только
|
||
для действительно исключительных ситуаций (throw); для редиректов — `System\Classes\HTTP::redirect()`
|
||
напрямую, не через эту иерархию (см. раздел Auth выше — почему).
|
||
|
||
### Routing (Route::resolve)
|
||
|
||
Автоматический роутинг без конфигурации, поиск контроллера по файловой системе в `App/Controller/`:
|
||
|
||
| URL | Контроллер | Метод |
|
||
|-----|-----------|-------|
|
||
| `/` | `IndexController` | `indexAction()` |
|
||
| `/users` | `UsersController` | `indexAction()` |
|
||
| `/users/list` | `UsersController` | `listAction()` |
|
||
| `/users/list/42` | `UsersController` | `listAction()`, `param(0)=42` |
|
||
| `/admin/users/edit` | `Admin/UsersController` | `editAction()` (subdir) |
|
||
|
||
Поиск файлов и имён контроллеров — без учёта регистра. Если контроллер не найден, Route возвращает flat-роутинг (не теряет action).
|
||
|
||
### View path convention
|
||
|
||
- Шаблон контента: `App/view/{Controller}/{template}.html`
|
||
- Layout: `App/view/{layout}.html` — единый шаблон на весь сайт (app-специфичный — не в `System`, там только нейтральные fallback-страницы вроде `errors/`, `exception/`)
|
||
- `Core::findFile($dir, $file, 'html')` ищет в `APPPATH/{dir}/{file}.html`, затем в `SYSPATH/{dir}/{file}.html`
|
||
- `$_paths` инициализируется лениво при первом вызове `findFile()`
|
||
- `Controller::render()` автоматически определяет `$dir` из имени класса (`App\Controller\FooController` → `view/Foo`)
|
||
|
||
### Собственные CSS/JS во view (System\Classes\View)
|
||
|
||
Файлы — в `App/media/css/` и `App/media/js/`. Content-шаблон подключает свои прямо в себе:
|
||
|
||
```php
|
||
<?php
|
||
$this->setStyle('about.css');
|
||
$this->setScript('about.js');
|
||
?>
|
||
```
|
||
|
||
`layout.html` выводит накопленное — `<?php $this->getStyles(); ?>` в `<head>`, `<?php $this->getScripts(); ?>`
|
||
перед `</body>` (есть также `setManifest()` для PWA-манифеста, выводится в `getStyles()`). `$this` внутри
|
||
шаблона — это объект `View`, который его рендерит (шаблон — `include` внутри `View::render()`), поэтому
|
||
методы вызываются как `$this->...`, как и `CSRF`/`View`-хелперы в других шаблонах.
|
||
|
||
Хранилище (`$_styles`/`$_scripts`/`$_manifest`) — статическое, общее на весь запрос: content и layout —
|
||
разные объекты `View` (content рендерится первым внутри `Controller::render()`, до создания layout-View),
|
||
но регистрация в content должна быть видна при выводе в layout. Файл ищется через `Core::findFile()`
|
||
(`APPPATH` → `SYSPATH`) — если не найден, в месте вызова `setStyle()`/`setScript()`/`setManifest()`
|
||
выводится `<div class="alert alert-danger">`.
|
||
|
||
### Controller hierarchy
|
||
|
||
Расстановка имён — как в проекте eoffice_v3:
|
||
|
||
- `System\Classes\BaseController` (abstract) — голое ядро: жизненный цикл `executeAction()` (`before()` → экшен → `after()`), хуки по умолчанию пустые. Нейтрально к вебу/API.
|
||
- `System\Classes\Controller extends BaseController` — веб: `render()` (layout + content) и `renderContent()` (только content, без layout — для ajax-фрагментов), авто-CSRF в `before()`, `json($data, $status)` для JSON-ответов. **Меню — не забота движка**: раньше `menuTop()`/`menuSide()` строили пункты меню PHP-массивом прямо в `System\Classes\Controller` — это было ошибкой (движок не должен знать структуру навигации конкретного проекта, см. `.claude/memory/feedback_engine_app_independence.md` в репозитории — тот же принцип, только для меню, а не для БД). Убрано; навигация — обычная разметка прямо в `App/view/layout.html`, без промежуточного PHP-слоя. Никакой проверки `isAjax()` нет — экшен сам решает, вызывать `render()` или `renderContent()`. CSS/JS — Bootstrap, Bootstrap Icons, jQuery из `/vendor/`. **Контроллеры приложения наследуют `Controller` напрямую.**
|
||
|
||
API-контроллер делается не отдельным классом, а флагом: `extends Controller` + `$_csrf_protection = false` + ответы через `json()` (так же, как в eoffice_v3).
|
||
|
||
**Текущие контроллеры приложения** (пересобираются заново после сброса, см.
|
||
`.claude/memory/бюджет_текущий_план.md`): `LoginController` (вход/выход, см. раздел Auth),
|
||
`IndexController` (дашборд/«Обзор», защищён `$_auth_protection`), `AccountsController` (CRUD
|
||
счетов — `createAction()/editAction()/deleteAction()`, JSON-ответы, без своего `indexAction()`/
|
||
страницы — вызывается ajax'ом с модалки прямо на дашборде, см. `App/view/Index/index.html` +
|
||
`App/media/js/accounts.js`; шаблон для будущих `CategoriesController`/`TransactionsController`/
|
||
`GoalsController` — тот же паттерн: `currentUser()`/владение записью через `user_id` → 404, а не
|
||
403, если чужой id; архивация (`is_delete=1`) вместо физического `delete()`, где это в схеме).
|
||
|
||
`Request::execute()` вызывает `executeAction({action}Action)`, поэтому `before()/after()` работают для любого контроллера прозрачно. Авторизация конкретного контроллера включается/выключается флагом `$_auth_protection` (см. раздел Auth), а не переопределением `before()` вручную.
|
||
|
||
### Environment
|
||
|
||
Среда задаётся через `SetEnv APP_ENV` в `.htaccess` (PRODUCTION / STAGING / TESTING / DEVELOPMENT).
|
||
По умолчанию — `DEVELOPMENT`. Значение читается в `index.php` через `getenv('APP_ENV')`.
|
||
|
||
### Apache
|
||
|
||
`ServerName localhost` добавлен в `/home/isaevea/http/docker-dev/conf/Bicycle/apache2/apache2.conf`.
|
||
**Никогда не трогать** `/home/isaevea/http/docker-dev/docker/php-apache/8.2.8/Dockerfile`.
|
||
|
||
### Tests
|
||
|
||
PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap.php`.
|
||
Константы определяются в bootstrap. `Core::init()` не вызывается — `findFile()` инициализирует `$_paths` лениво.
|
||
|
||
**PHPStorm**: интерпретатор PHP — Docker (`bicycle`), volume маппинг `/home/isaevea/http/Bicycle` → `/opt/project`, путь к autoload: `/opt/project/vendor/autoload.php`, конфиг: `/opt/project/phpunit.xml`.
|
||
|
||
| Файл | Покрытие |
|
||
|------|---------|
|
||
| `tests/Unit/RouteTest.php` | `Route::resolve()`, case-insensitive, params, fallback |
|
||
| `tests/Unit/ResponseTest.php` | `body()`, `status()`, fluent chain |
|
||
| `tests/Unit/MyExceptionTest.php` | `strtr` замена, code, previous |
|
||
| `tests/Unit/CoreTest.php` | `findFile()`, `getConst()`, environment |
|
||
| `tests/Unit/RequestTest.php` | `factory()`, `post()`, `query()`, `body()`, `isAjax()`, `method()` |
|
||
| `tests/Unit/ViewTest.php` | `render()`, `setFilename()`, data extract, `setStyle()`/`setScript()`/`setManifest()` + `getStyles()`/`getScripts()` |
|
||
| `tests/Unit/HttpClientRequestTest.php` | `HTTP\Client\Request` builder, json, fluent |
|
||
| `tests/Unit/CookieTest.php` | `get()`, `set()`, `delete()`, defaults |
|
||
| `tests/Unit/SessionTest.php` | singleton, `get/set/delete/destroy/regenerate/bind` |
|
||
| `tests/Unit/ConfigTest.php` | `get()`, `set()`, `merge()`, lazy-load |
|
||
| `tests/Unit/HTTPExceptionTest.php` | `factory()`, subclasses, `getResponse()`, codes |
|
||
| `tests/Unit/ValidationTest.php` | правила, `matches`, пропуск пустых, first-error, label/плейсхолдеры, fluent |
|
||
| `tests/Unit/CSRFTest.php` | `token()` стабильность/формат, `validate()`, `field()`, нет токена в сессии |
|
||
| `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` | уровни/порог, `enabled` (выкл — ничего не пишет), каналы (action/error), формат, append, `strtr`, маскировка, `requestInfo()` |
|
||
| `tests/Unit/FileLogReaderTest.php` | парсинг, фильтры (level/q/channel), newest-first, missing file, `dates()` |
|
||
| `tests/Unit/FileAuthDriverTest.php` | `login()` верно/неверно/неизвестный логин, `loggedIn()`, `getUser()` без пароля, `logout()`, `checkPassword()`, отсутствие файла |
|
||
| `tests/Unit/AuthTest.php` | `instance()` драйвер по умолчанию/явный, кэширование по драйверу, неизвестный драйвер → исключение |
|
||
| `tests/Unit/DatabaseTest.php` | `instance()` кэш по имени подключения, неизвестное подключение/драйвер → исключение |
|
||
| `tests/Unit/PdoConnectionTest.php` | failover (не требует БД), `query()`, транзакции/вложенные SAVEPOINT, `transaction()`, профилирование ручного `prepare()+execute()` — требует живую MariaDB, иначе `markTestSkipped()`; отдельно диалект `sqlite` (`:memory:`, не требует внешней БД) |
|
||
| `tests/Unit/StatementTest.php` | `showQuery()`/`sq()` — подстановка позиционных/именованных параметров — требует живую MariaDB |
|
||
| `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, вкладки Vars/Files/Route, маскировка чувствительных ключей, вкладка Custom только при `addData()`, `EXPLAIN` для реального SELECT / пропуск для не-SELECT |
|
||
|
||
**248 тестов, 387 assertion — все проходят (8 skipped: Elasticsearch/Mongo без живой инфраструктуры —
|
||
MariaDB подключена и все её тесты реально проходят, см. разделы DataBase/Elasticsearch/Mongo выше).**
|
||
|
||
### Frontend dependencies (через Composer)
|
||
|
||
Bootstrap 5.3, Bootstrap Icons 1.13, jQuery 3.7.1, jQuery UI 1.12, Select2 4.1 — из `vendor/`.
|
||
|
||
## Дополнительно
|
||
|
||
Узкие темы (конвенции именования, правила работы с Claude Code, roadmap и т.п.) — не здесь, а в
|
||
`.claude/memory/` (по одному файлу на тему, индекс — `.claude/memory/MEMORY.md`). Этот файл — только
|
||
общая архитектура и команды.
|