diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 14c34fc..4f98e22 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -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 для миграций/крона diff --git a/.claude/memory/feedback_reference_code_handling.md b/.claude/memory/feedback_reference_code_handling.md new file mode 100644 index 0000000..747f058 --- /dev/null +++ b/.claude/memory/feedback_reference_code_handling.md @@ -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`. diff --git a/.claude/memory/roadmap.md b/.claude/memory/roadmap.md index 0561cd4..14f7b87 100644 --- a/.claude/memory/roadmap.md +++ b/.claude/memory/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-точка входа отложена именно до появления этого слоя (первый реальный потребитель — миграции; теперь, +когда слой БД есть, эта идея стала актуальнее). diff --git a/App/Controller/IndexController.php b/App/Controller/IndexController.php index e4b86fe..968c4e2 100644 --- a/App/Controller/IndexController.php +++ b/App/Controller/IndexController.php @@ -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 { diff --git a/App/Repositories/BudgetRepository.php b/App/Repositories/BudgetRepository.php index f800c75..fbfbf11 100644 --- a/App/Repositories/BudgetRepository.php +++ b/App/Repositories/BudgetRepository.php @@ -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(); } } diff --git a/CLAUDE.md b/CLAUDE.md index 388652e..a104a6c 100644 --- a/CLAUDE.md +++ b/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` -(`` перед ``) — рендерится для любой страницы -сайта, но `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` (`` перед +``) — рендерится для любой страницы сайта, но `render()` отдаёт пустую строку всем, кроме +`role === 'admin'` (проверка через `Auth::instance()->getUser()`, как и остальные ролевые проверки +в проекте). Верхняя строка сворачивает/разворачивает панель, кнопки переключают вкладки — инлайн +`onclick` + один ` HTML; } diff --git a/System/Classes/Repository.php b/System/Classes/Repository.php index 9dcac62..c9a377f 100644 --- a/System/Classes/Repository.php +++ b/System/Classes/Repository.php @@ -42,15 +42,15 @@ abstract class Repository private string $obj_class; /** - * @param string $table Таблица - * @param class-string $obj_class Класс строки (fetchObject()); по умолчанию — лёгкий Model + * @param string $table Таблица + * @param class-string $obj_class Класс строки (fetchObject()); по умолчанию — лёгкий Model * @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию * @throws MyException */ public function __construct(string $table, string $obj_class = Model::class, ?string $connection = null) { - $this->pdo = Database::instance($connection); - $this->table = $table; + $this->pdo = Database::instance($connection); + $this->table = $table; $this->obj_class = $obj_class; } @@ -97,6 +97,7 @@ abstract class Repository * @param callable $callback * @return mixed * @throws MyException + * @throws Throwable */ public function transaction(callable $callback): mixed { @@ -123,7 +124,7 @@ abstract class Repository */ public function get(int|string $id): mixed { - $sql = "SELECT * FROM $this->table WHERE $this->primary_col = :id"; + $sql = "SELECT * FROM $this->table WHERE $this->primary_col = :id"; $stmt = $this->pdo->prepare($sql); $stmt->bindValue(':id', $id, is_int($id) ? PDO::PARAM_INT : PDO::PARAM_STR); $stmt->execute(); @@ -152,16 +153,16 @@ abstract class Repository /** * Получить список. * - * @param string $cols 'id, col_1, col_2, ...' + * @param string $cols 'id, col_1, col_2, ...' * @param array $where ['table_col' => 'value', ...] (объединяются через AND; значение 'IS NULL'/'IS NOT NULL' — без плейсхолдера) * @param string $order Колонка сортировки - * @param string $sort ASC|DESC - * @param int $fetch Режим PDO::FETCH_* + * @param string $sort ASC|DESC + * @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); } /** @@ -204,9 +205,9 @@ abstract class Repository $create_data = $this->processData($data); $fields = implode(',', $create_data['fields']); - $keys = implode(',', $create_data['keys']); + $keys = implode(',', $create_data['keys']); - $sql = "INSERT INTO $this->table ($fields) VALUES ($keys)"; + $sql = "INSERT INTO $this->table ($fields) VALUES ($keys)"; $stmt = $this->pdo->prepare($sql); foreach ($create_data['bind'] as $param) { @@ -215,7 +216,7 @@ abstract class Repository $stmt->execute(); - return (int) $this->pdo->lastInsertId(); + return (int)$this->pdo->lastInsertId(); } /** @@ -257,7 +258,7 @@ abstract class Repository */ public function delete(int|string $id): bool { - $sql = "DELETE FROM $this->table WHERE $this->primary_col = :id"; + $sql = "DELETE FROM $this->table WHERE $this->primary_col = :id"; $stmt = $this->pdo->prepare($sql); $stmt->bindValue(':id', $id, is_int($id) ? PDO::PARAM_INT : PDO::PARAM_STR); @@ -267,7 +268,7 @@ abstract class Repository /** * Удалить записи по произвольному условию. * - * @param string $where SQL-условие после WHERE (с плейсхолдерами) + * @param string $where SQL-условие после WHERE (с плейсхолдерами) * @param array $params Параметры для плейсхолдеров * @return bool * @throws MyException @@ -301,7 +302,7 @@ abstract class Repository } $result['fields'][] = "`$key`"; - $result['keys'][] = ":$key"; + $result['keys'][] = ":$key"; if (is_int($value)) { $result['bind'][] = [":$key", $value, PDO::PARAM_INT]; diff --git a/tests/Unit/PdoDriverTest.php b/tests/Unit/PdoDriverTest.php index 71f3bf5..6c60312 100644 --- a/tests/Unit/PdoDriverTest.php +++ b/tests/Unit/PdoDriverTest.php @@ -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()); diff --git a/tests/Unit/ProfilerTest.php b/tests/Unit/ProfilerTest.php index 76540e2..425607b 100644 --- a/tests/Unit/ProfilerTest.php +++ b/tests/Unit/ProfilerTest.php @@ -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']); - } } diff --git a/tests/Unit/ProfilerToolbarTest.php b/tests/Unit/ProfilerToolbarTest.php index 19f22eb..ae07ab5 100644 --- a/tests/Unit/ProfilerToolbarTest.php +++ b/tests/Unit/ProfilerToolbarTest.php @@ -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-класса в