From 9d4edf147ab79ad3241e3a501a2e8a3bd6200e37 Mon Sep 17 00:00:00 2001 From: Egor Isaev Date: Fri, 7 Aug 2026 11:40:03 +0300 Subject: [PATCH] dev --- .claude/memory/MEMORY.md | 3 +- .../memory/feedback_no_unprompted_scope.md | 26 ++ .claude/memory/roadmap.md | 47 ++- App/Controller/AboutController.php | 26 ++ App/Controller/Admin/AdminController.php | 2 + App/Controller/IndexController.php | 5 +- App/Controller/NewsController.php | 30 ++ App/Repositories/BudgetRepository.php | 38 ++ App/config/config.php | 24 +- App/media/css/app.css | 1 + App/media/js/app.js | 1 + App/media/manifest.json | 6 + App/view/About/about.html | 13 + App/view/Index/index.html | 4 +- App/view/News/news.html | 18 + App/view/layout.html | 40 +- App/view/menu_side.html | 16 + App/view/menu_top.html | 23 ++ CLAUDE.md | 187 +++++++++- Services/DataBase/Classes/PdoDriver.php | 217 +++++++++++ Services/DataBase/Classes/Profiler.php | 68 ++++ Services/DataBase/Classes/ProfilerPDO.php | 34 ++ Services/DataBase/Classes/Statement.php | 96 +++++ Services/DataBase/Model.php | 58 +++ Services/Database.php | 58 +++ Services/Elasticsearch.php | 47 +++ Services/Elasticsearch/Client.php | 152 ++++++++ Services/Mongo.php | 52 +++ Services/Mongo/MongoDriver.php | 67 ++++ System/Classes/Controller.php | 87 ++++- System/Classes/ProfilerToolbar.php | 91 +++++ System/Classes/Repository.php | 325 ++++++++++++++++ System/Classes/View.php | 112 ++++++ composer.json | 9 +- composer.lock | 351 ++++++++++++++---- tests/Unit/DatabaseTest.php | 61 +++ tests/Unit/ElasticsearchClientTest.php | 59 +++ tests/Unit/ElasticsearchTest.php | 42 +++ tests/Unit/ModelTest.php | 64 ++++ tests/Unit/MongoDriverTest.php | 53 +++ tests/Unit/MongoTest.php | 34 ++ tests/Unit/PdoDriverTest.php | 133 +++++++ tests/Unit/ProfilerTest.php | 60 +++ tests/Unit/ProfilerToolbarTest.php | 82 ++++ tests/Unit/RepositoryTest.php | 157 ++++++++ tests/Unit/StatementTest.php | 52 +++ tests/Unit/ViewTest.php | 104 ++++++ 47 files changed, 3090 insertions(+), 145 deletions(-) create mode 100644 .claude/memory/feedback_no_unprompted_scope.md create mode 100644 App/Controller/AboutController.php create mode 100644 App/Controller/NewsController.php create mode 100644 App/Repositories/BudgetRepository.php create mode 100644 App/media/css/app.css create mode 100644 App/media/js/app.js create mode 100644 App/media/manifest.json create mode 100644 App/view/About/about.html create mode 100644 App/view/News/news.html create mode 100644 App/view/menu_side.html create mode 100644 App/view/menu_top.html create mode 100644 Services/DataBase/Classes/PdoDriver.php create mode 100644 Services/DataBase/Classes/Profiler.php create mode 100644 Services/DataBase/Classes/ProfilerPDO.php create mode 100644 Services/DataBase/Classes/Statement.php create mode 100644 Services/DataBase/Model.php create mode 100644 Services/Database.php create mode 100644 Services/Elasticsearch.php create mode 100644 Services/Elasticsearch/Client.php create mode 100644 Services/Mongo.php create mode 100644 Services/Mongo/MongoDriver.php create mode 100644 System/Classes/ProfilerToolbar.php create mode 100644 System/Classes/Repository.php create mode 100644 tests/Unit/DatabaseTest.php create mode 100644 tests/Unit/ElasticsearchClientTest.php create mode 100644 tests/Unit/ElasticsearchTest.php create mode 100644 tests/Unit/ModelTest.php create mode 100644 tests/Unit/MongoDriverTest.php create mode 100644 tests/Unit/MongoTest.php create mode 100644 tests/Unit/PdoDriverTest.php create mode 100644 tests/Unit/ProfilerTest.php create mode 100644 tests/Unit/ProfilerToolbarTest.php create mode 100644 tests/Unit/RepositoryTest.php create mode 100644 tests/Unit/StatementTest.php diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index ed84338..14c34fc 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -7,8 +7,9 @@ - [Не коммитить самому](feedback_no_commit.md) — git commit/push делает пользователь вручную - [Общение по-русски](feedback_russian_communication.md) — включая описания Bash-вызовов и подобных tool-call'ов - [Меньше уточнений](feedback_less_confirmation.md) — не переспрашивать по некритичным/обратимым шагам +- [Не делать лишнего](feedback_no_unprompted_scope.md) — не выносить абстракции "про запас", не додумывать причины, вопрос ≠ команда действовать - [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) — Model, Database/PdoDriver, Statement, Repository, Profiler +- [Roadmap: слой БД](roadmap.md) — MariaDB/PDO реализован и протестирован; Mongo/Elasticsearch написаны, ждут внешней инфры от пользователя - [Идея: CLI-точка входа](idea_cli_entrypoint.md) — отложенный cli.php для миграций/крона diff --git a/.claude/memory/feedback_no_unprompted_scope.md b/.claude/memory/feedback_no_unprompted_scope.md new file mode 100644 index 0000000..59bfa1f --- /dev/null +++ b/.claude/memory/feedback_no_unprompted_scope.md @@ -0,0 +1,26 @@ +--- +name: feedback-no-unprompted-scope +description: Не делать лишних действий сверх того, что попросили — не выносить абстракции "на будущее", не додумывать причины +metadata: + type: feedback +--- + +Пользователь прямо указал: «ты делаешь много лишних действий которые тебя не просят». Конкретный кейс — +при рефакторинге `Controller::render()`/`renderContent()` (см. [[project-bicycle-overview]]) я вынес +расчёт каталога view в отдельный приватный метод `contentDir()` без реальной причины (вызывался из +одного места) — просто рефлекс «похожая логика — надо вынести», а не ответ на реальную потребность. +Пользователь спросил «мы где-то ещё будем использовать contentDir()?» не как команду переделать, +а как проверочный вопрос — заподозрил лишнюю абстракцию и хотел понять мою логику. + +**Why:** Лишние абстракции/методы «про запас» усложняют код без пользы (см. также правило проекта — +не проектировать под гипотетические будущие требования) и создают у пользователя ощущение, что я +действую самостоятельно там, где не просили. + +**How to apply:** Не выносить код в отдельный метод/абстракцию, если на это нет явного запроса или +явного текущего переиспользования (2+ реальных вызова прямо сейчас). Если сомневаюсь, нужна ли +абстракция — оставлять код инлайн, как было. Это не противоречит [[feedback-less-confirmation]]: +там речь про паузы-подтверждения на уже согласованных, обратимых шагах; здесь — про то, чтобы вообще +не расширять объём работы (лишние методы, лишний рефакторинг, лишние файлы) сверх того, что запросили. +Если вопрос пользователя похож на «а мы это ещё где-то используем/будем использовать?» — это, как +правило, не команда действовать, а проверка моей логики; сначала ответить прямо, не хвататься за +редактирование кода. \ No newline at end of file diff --git a/.claude/memory/roadmap.md b/.claude/memory/roadmap.md index b00e056..0561cd4 100644 --- a/.claude/memory/roadmap.md +++ b/.claude/memory/roadmap.md @@ -1,20 +1,47 @@ --- name: roadmap -description: Что ещё не реализовано в Bicycle — слой БД и связанные классы +description: Статус слоя БД (MariaDB/Mongo/Elasticsearch) в Bicycle — что реализовано, что ждёт внешней инфраструктуры metadata: type: project --- -Запланировано, но пока не реализовано (по состоянию на 2026-08-06): +Реализовано (2026-08-07) — три независимых сервиса, каждый со своей фабрикой в духе `Services\Auth`, +без общего интерфейса поверх всех трёх (SQL/документы/поиск — слишком разные модели, чтобы прятать +за одной абстракцией). Подробности использования — CLAUDE.md → разделы DataBase/Elasticsearch/Mongo. -- `Model` — лёгкий base-класс с `__get`/`__set`, `toArray()`. -- `Database` + `PdoDriver` — PDO singleton с failover. -- `Statement` — расширение `PDOStatement` (`showQuery()`/`sq()`). -- `Repository` — abstract CRUD с транзакциями/SAVEPOINT. -- `Profiler` + `ProfilerPDO` — тайминги SQL-запросов. +- **Реляционная БД (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` — так решил пользователь явно. +- **Elasticsearch** — `Services\Elasticsearch` + `Services\Elasticsearch\Client`, тонкая REST-обёртка на + уже существующем `System\Classes\HTTP\Client\Curl` (без тяжёлой composer-зависимости + `elasticsearch/elasticsearch` — см. `feedback_educational_project.md`). +- **Mongo** — `Services\Mongo` + `Services\Mongo\MongoDriver`, обёртка над `MongoDB\Client` + (композер-пакет `mongodb/mongodb` уже поставлен через `composer.phar require --ignore-platform-req=ext-mongodb`, + плюс `"platform": {"ext-mongodb": "2.3"}` в `composer.json`, чтобы дальнейшие `composer install` не + требовали флага). -Также пустые каталоги-заготовки без кода: `Services/DataBase`, `Services/Mail`, `Services/PDF`, `App/Classes/` (см. CLAUDE.md → Directory layout). +**Что ждёт внешней инфраструктуры (не моя зона — пользователь занимается сам):** -**Why:** Это следующий крупный слой фреймворка (доступ к БД) — под него уже есть заготовка в composer.json/каталогах, но кода ещё нет. +- `ext-mongodb` не установлен в `docker/php-apache/8.2.8/Dockerfile` (общий для нескольких проектов + на хосте — мне трогать нельзя). Пользователь добавит сам. Код написан безопасно без расширения — + падает только на реальном `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 (если с авторизацией) — + пользователь впишет сам. -**How to apply:** При запросах, касающихся БД/моделей/репозиториев — предлагать реализацию в этом стиле (не ORM-фреймворк вроде Doctrine/Eloquent, а лёгкая обёртка в духе остального проекта). См. также `idea_cli_entrypoint.md` — CLI-точка входа отложена именно до появления этого слоя (первый реальный потребитель — миграции). +**Тесты**: `PdoDriverTest`/`StatementTest`/`RepositoryTest` бьют по реальной MariaDB (не мокают) — пока +БД не настроена, гейтятся через `markTestSkipped()` в `setUp()` при неудачном подключении. +`ElasticsearchClientTest` — то же самое через `Client::ping()`. `MongoDriverTest` — через +`extension_loaded('mongodb')`. Все они оживут сами, без правки кода, как только пользователь настроит +соответствующую инфраструктуру — сейчас (2026-08-07) 26 тестов из 229 пропущены именно поэтому. + +**How to apply:** При запросах, касающихся БД/моделей/репозиториев — этот слой уже есть, не писать +заново с нуля, расширять существующие классы. Новый конкретный репозиторий — в `App/Repositories/* +extends System\Classes\Repository`, не в `Services\DataBase`. См. также `idea_cli_entrypoint.md` — +CLI-точка входа отложена именно до появления этого слоя (первый реальный потребитель — миграции; +теперь, когда слой БД есть, эта идея стала актуальнее). diff --git a/App/Controller/AboutController.php b/App/Controller/AboutController.php new file mode 100644 index 0000000..96dcd41 --- /dev/null +++ b/App/Controller/AboutController.php @@ -0,0 +1,26 @@ +render('about'); + } +} diff --git a/App/Controller/Admin/AdminController.php b/App/Controller/Admin/AdminController.php index 34af20e..4a3e9d3 100644 --- a/App/Controller/Admin/AdminController.php +++ b/App/Controller/Admin/AdminController.php @@ -13,6 +13,8 @@ use System\Classes\Controller; /** * Базовый контроллер админки. Рендерится в общем layout сайта * (App/view/layout.html), своего отдельного шаблона не имеет. + * Боковое меню — общее для всего сайта, см. Controller::menuSide() + * (для роли admin оно показывается на любой странице, не только в админке). * Требует авторизацию с ролью admin (см. Controller::$_auth_protection/$_auth_roles) — * неавторизованный редиректится на /login, авторизованный без роли admin получает 403. * Конкретные страницы (Logs и т.п.) наследуют его. diff --git a/App/Controller/IndexController.php b/App/Controller/IndexController.php index 5c01403..e4b86fe 100644 --- a/App/Controller/IndexController.php +++ b/App/Controller/IndexController.php @@ -8,6 +8,7 @@ namespace App\Controller; +use App\Repositories\BudgetRepository; use System\Classes\Controller; use System\Classes\HTTP\Client\Curl; use System\Classes\HTTP\Client\Request as HttpRequest; @@ -25,7 +26,9 @@ class IndexController extends Controller */ public function indexAction(): string { - $todo = []; + $budgetR = new BudgetRepository(); + + var_dump($budgetR->getLists());die; return $this->render('index', ['todo' => $todo]); } diff --git a/App/Controller/NewsController.php b/App/Controller/NewsController.php new file mode 100644 index 0000000..d24a755 --- /dev/null +++ b/App/Controller/NewsController.php @@ -0,0 +1,30 @@ + '2026-08-07', 'title' => 'Запущен Bicycle', 'text' => 'Первый релиз самописного PHP MVC-фреймворка.'], + ]; + + return $this->render('news', ['news' => $news]); + } +} diff --git a/App/Repositories/BudgetRepository.php b/App/Repositories/BudgetRepository.php new file mode 100644 index 0000000..f800c75 --- /dev/null +++ b/App/Repositories/BudgetRepository.php @@ -0,0 +1,38 @@ +pdo->prepare($sql); + $stmt->execute(); + + return $stmt->fetchAll(PDO::FETCH_CLASS); + } +} diff --git a/App/config/config.php b/App/config/config.php index e2c815b..f2a8096 100644 --- a/App/config/config.php +++ b/App/config/config.php @@ -10,14 +10,30 @@ return [ 'default' => [ 'driver' => 'pdo', 'connection' => [ - 'host' => 'db', - 'dbname' => 'bicycle', - 'user' => 'root', - 'password' => '', + 'host' => '192.168.1.104', + 'user' => 'root', + 'password' => 'password', + 'dbname' => 'MatMed_BudgetV2' ], ], ], + 'elasticsearch' => [ + 'default' => [ + 'base_url' => 'http://elasticsearch:9200', + 'timeout' => 10, + 'username' => null, + 'password' => null, + ], + ], + + 'mongo' => [ + 'default' => [ + 'uri' => 'mongodb://mongo:27017', + 'database' => 'bicycle', + ], + ], + 'session' => [ 'cookie_params' => [ 'lifetime' => 0, diff --git a/App/media/css/app.css b/App/media/css/app.css new file mode 100644 index 0000000..91df627 --- /dev/null +++ b/App/media/css/app.css @@ -0,0 +1 @@ +/* Bicycle — собственные стили сайта поверх Bootstrap */ diff --git a/App/media/js/app.js b/App/media/js/app.js new file mode 100644 index 0000000..969aa95 --- /dev/null +++ b/App/media/js/app.js @@ -0,0 +1 @@ +// Bicycle — собственный JS сайта diff --git a/App/media/manifest.json b/App/media/manifest.json new file mode 100644 index 0000000..e8570e9 --- /dev/null +++ b/App/media/manifest.json @@ -0,0 +1,6 @@ +{ + "name": "Bicycle", + "short_name": "Bicycle", + "start_url": "/", + "display": "standalone" +} diff --git a/App/view/About/about.html b/App/view/About/about.html new file mode 100644 index 0000000..d5a5b0a --- /dev/null +++ b/App/view/About/about.html @@ -0,0 +1,13 @@ +setStyle('app.css'); +$this->setScript('app.js'); +?> + +

О нас

+

Bicycle — самописный PHP MVC micro-framework. Название отражает идиому «изобрести велосипед».

diff --git a/App/view/Index/index.html b/App/view/Index/index.html index ad16d76..8f7051a 100644 --- a/App/view/Index/index.html +++ b/App/view/Index/index.html @@ -8,4 +8,6 @@ ?> - +

Главная

+

Добро пожаловать в Bicycle — самописный PHP MVC micro-framework.

+ diff --git a/App/view/News/news.html b/App/view/News/news.html new file mode 100644 index 0000000..0861a1b --- /dev/null +++ b/App/view/News/news.html @@ -0,0 +1,18 @@ + + +

Новости

+ + +
+
+

+

+
+ diff --git a/App/view/layout.html b/App/view/layout.html index fd8aea8..25c9852 100644 --- a/App/view/layout.html +++ b/App/view/layout.html @@ -1,10 +1,12 @@ @@ -15,27 +17,25 @@ Bicycle + getStyles(); ?> - -
- -
+ +
+ +
+ +getScripts(); ?> + diff --git a/App/view/menu_side.html b/App/view/menu_side.html new file mode 100644 index 0000000..cf6733b --- /dev/null +++ b/App/view/menu_side.html @@ -0,0 +1,16 @@ + +
+ + + + + +
diff --git a/App/view/menu_top.html b/App/view/menu_top.html new file mode 100644 index 0000000..0dd8723 --- /dev/null +++ b/App/view/menu_top.html @@ -0,0 +1,23 @@ + + diff --git a/CLAUDE.md b/CLAUDE.md index db1c3bb..388652e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,17 +38,21 @@ System/Classes/HTTP/ — HTTP-инфраструктура: инт System/Classes/HTTP/Client/ — исходящие HTTP-запросы (Curl, Request, Response) System/Classes/HTTP/Exception/ — HTTPException_302/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/, Mail/, PDF/ — пустые каталоги-заготовки под будущие сервисы (PDO / PHPMailer / dompdf); кода пока нет +Services/Database.php, DataBase/ — реляционная БД (MariaDB/PDO): фабрика + Model/Classes/{PdoDriver,Statement,Profiler,ProfilerPDO} (см. раздел 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 тесты ``` -> Каталог `App/Classes/` и часть `Services/*` (DataBase, Mail, PDF) ещё не созданы — namespace-конвенции ниже описывают, *куда* класть код, когда он появится, а не существующие файлы. +> Каталог `App/Classes/` и часть `Services/*` (Mail, PDF) ещё не созданы — namespace-конвенции ниже описывают, *куда* класть код, когда он появится, а не существующие файлы. ### Autoload (composer.json) @@ -76,8 +80,9 @@ PhpStorm может показывать предупреждение «Namespac |---|---| | `System\Classes\Core` | Bootstrap, обработка ошибок, поиск файлов (`findFile`), константы среды | | `System\Classes\BaseController` | Голое ядро (abstract); жизненный цикл `executeAction()`: `before()` → экшен → `after()` | -| `System\Classes\Controller` | Веб-контроллер (extends BaseController): `render()` + авто-CSRF в `before()` + `json()` | -| `System\Classes\View` | Рендеринг `.html`-шаблонов через `ob_start` + `extract` + `include` | +| `System\Classes\Controller` | Веб-контроллер (extends BaseController): `render()`/`renderContent()` + авто-CSRF в `before()` + `json()` | +| `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()` — геттер/сеттер содержимого | @@ -104,6 +109,17 @@ PhpStorm может показывать предупреждение «Namespac | `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): 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\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\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 @@ -293,6 +309,128 @@ return [ «Войти» либо «Выйти (логин)» в зависимости от `Auth::instance()->getUser()`; пункт «Админка» виден только при `role === 'admin'`. +### DataBase (Services\Database, System\Classes\Repository) + +Реляционная БД (MariaDB) через PDO. Конфиг — `Config::get('db', $name)`, ключ `$name` — это имя +*подключения* (не тип драйвера — драйвер сейчас всегда `'pdo'`), по умолчанию `'default'`: + +```php +use Services\Database; + +$driver = Database::instance(); // Config::get('db', 'default') +$stmt = $driver->query('SELECT * FROM users WHERE id = ?', [42]); +$row = $stmt->fetch(); + +echo $stmt->sq(); // SQL с подставленными параметрами — для дебага (Statement::showQuery()) +``` + +Подключение — ленивое (первое обращение к `pdo()`/`query()`), с failover: `connection.hosts` +(массив) перебирается по порядку до первого успешного, иначе — `MyException` со списком ошибок +по каждому хосту. Транзакции на уровне `PdoDriver` — вложенные через SAVEPOINT +(`beginTransaction()`/`commit()`/`rollback()` считают уровень вложенности сами), либо через обёртку: + +```php +$driver->transaction(function ($driver) { + $driver->query('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, 1]); + $driver->query('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, 2]); +}); +// исключение внутри колбэка → rollback (или ROLLBACK TO SAVEPOINT на вложенном уровне) + повторный throw +``` + +Тайминги запросов — `Services\DataBase\Classes\Profiler::entries()`/`totalTime()` (наполняется +автоматически из `PdoDriver::query()` через `ProfilerPDO`). + +**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 вторым аргументом, по умолчанию 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 — делегируют в PdoDriver (там же и SAVEPOINT-логика) +``` + +`public string $primary_col = 'id'` и `public bool $is_auto_increment = true` — переопределяются в +наследнике при необходимости. `processData()` (protected) — общая сборка bind-параметров для +`create()`/`update()` с автоопределением `PDO::PARAM_*` по типу значения (массив кодируется в JSON). + +`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)): +время выполнения запроса, пиковая память, список SQL-запросов с параметрами (из +`Services\DataBase\Classes\Profiler`, см. раздел DataBase выше). Подключена прямо в `App/view/layout.html` +(`` перед ``) — рендерится для любой страницы +сайта, но `render()` отдаёт пустую строку всем, кроме `role === 'admin'` (проверка через +`Auth::instance()->getUser()`, как и остальные ролевые проверки в проекте). Кликабельная строка сворачивает/ +разворачивает таблицу запросов (инлайн `onclick`, без внешнего JS/CSS — панель самодостаточна). + ### Log (System\Classes\Log) Файловый логгер в стиле `Config`/`Cookie`. Пишет в два файла, именованных датой: @@ -363,16 +501,38 @@ $response = HTTPException::factory(403)->getResponse(); - `$_paths` инициализируется лениво при первом вызове `findFile()` - `Controller::render()` автоматически определяет `$dir` из имени класса (`App\Controller\FooController` → `view/Foo`) +### Собственные CSS/JS во view (System\Classes\View) + +Файлы — в `App/media/css/` и `App/media/js/`. Content-шаблон подключает свои прямо в себе: + +```php +setStyle('about.css'); +$this->setScript('about.js'); +?> +``` + +`layout.html` выводит накопленное — `getStyles(); ?>` в ``, `getScripts(); ?>` +перед `` (есть также `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()` +выводится `
`. + ### Controller hierarchy Расстановка имён — как в проекте eoffice_v3: - `System\Classes\BaseController` (abstract) — голое ядро: жизненный цикл `executeAction()` (`before()` → экшен → `after()`), хуки по умолчанию пустые. Нейтрально к вебу/API. -- `System\Classes\Controller extends BaseController` — веб: `render()` (layout + content), авто-CSRF в `before()`, `json($data, $status)` для JSON-ответов, хуки `layoutData()` (доп. данные в layout помимо `content`; по умолчанию `['menu' => $this->menu()]`) и `menu()` (пункты главного меню сайта, активный — по текущему URI). Layout — `App/view/layout.html` (Bootstrap-навбар сверху с меню сайта, `$content` внутри `
`; CSS/JS — Bootstrap, Bootstrap Icons, jQuery из `/vendor/`). Единый для всего сайта, включая админку. **Контроллеры приложения наследуют `Controller` напрямую.** +- `System\Classes\Controller extends BaseController` — веб: `render()` (layout + верхнее/боковое меню + content) и `renderContent()` (только content, без layout и меню — для ajax-фрагментов), авто-CSRF в `before()`, `json($data, $status)` для JSON-ответов. Меню — отдельные хуки: `menuTop()`/`menuSide()` отдают пункты (массив `[['title','url','active'], ...]`), `renderMenuTop()`/`renderMenuSide()` рендерят их через `App/view/menu_top.html`/`menu_side.html` в готовый HTML *до* передачи в layout (`renderMenuSide()` — пустая строка, если пунктов нет). Оба хука — общие для всего сайта (не переопределяются в конкретных контроллерах), активный пункт — по текущему URI, видны на любой странице любому пользователю независимо от роли/авторизации (сейчас в `menuSide()` заглушка — «Главная», «О нас», «Новости»; для последних двух заведены `AboutController`/`NewsController`). Layout — `App/view/layout.html`: верхнее меню всегда, боковое — колонка `aside` (Bootstrap grid) появляется, только если `$menu_side` не пуст; иначе `content` на всю ширину `
`. Никакой проверки `isAjax()` нет — экшен сам решает, вызывать `render()` или `renderContent()`. CSS/JS — Bootstrap, Bootstrap Icons, jQuery из `/vendor/`. Единый layout для всего сайта, включая админку. **Контроллеры приложения наследуют `Controller` напрямую.** API-контроллер делается не отдельным классом, а флагом: `extends Controller` + `$_csrf_protection = false` + ответы через `json()` (так же, как в eoffice_v3). -**Админка** (`App/Controller/Admin/`): базовый `App\Controller\Admin\AdminController extends Controller` своего layout/меню не задаёт — рендерится в том же `App/view/layout.html`, что и весь сайт (в меню есть пункт «Админка»). Включает `$_auth_protection = true` — доступ только авторизованным (см. [Auth](#auth-servicesauth)), неавторизованный редиректится на `/login`. Конкретные страницы (`LogsController` и т.п.) наследуют `AdminController`. +**Админка** (`App/Controller/Admin/`): базовый `App\Controller\Admin\AdminController extends Controller` своего layout и меню не задаёт — рендерится в том же `App/view/layout.html`, что и весь сайт, с тем же общим `Controller::menuSide()` (не связан с админкой — заглушка на весь сайт). Включает `$_auth_protection = true` — доступ только авторизованным (см. [Auth](#auth-servicesauth)), неавторизованный редиректится на `/login`. Конкретные страницы (`LogsController` и т.п.) наследуют `AdminController`. `Request::execute()` вызывает `executeAction({action}Action)`, поэтому `before()/after()` работают для любого контроллера прозрачно. Авторизация конкретного контроллера включается/выключается флагом `$_auth_protection` (см. раздел Auth), а не переопределением `before()` вручную. @@ -400,7 +560,7 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap | `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 | +| `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` | @@ -413,8 +573,19 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap | `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/PdoDriverTest.php` | failover (не требует БД), `query()`, транзакции/вложенные SAVEPOINT, `transaction()` — требует живую MariaDB, иначе `markTestSkipped()` | +| `tests/Unit/StatementTest.php` | `showQuery()`/`sq()` — подстановка позиционных/именованных параметров — требует живую MariaDB | +| `tests/Unit/ProfilerTest.php` | `log()/entries()/totalTime()/count()/reset()`, интеграция через `ProfilerPDO::wrap()` | +| `tests/Unit/RepositoryTest.php` | CRUD, `all()` с `where`, `transaction()` с rollback — требует живую MariaDB | +| `tests/Unit/ModelTest.php` | `__get/__set/__isset/toArray()` | +| `tests/Unit/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 | -**177 тестов, 290 assertion — все проходят.** +**233 теста, 335 assertion — все проходят (26 skipped: MariaDB/Elasticsearch/Mongo без живой инфраструктуры — см. разделы DataBase/Elasticsearch/Mongo выше).** ### Frontend dependencies (через Composer) diff --git a/Services/DataBase/Classes/PdoDriver.php b/Services/DataBase/Classes/PdoDriver.php new file mode 100644 index 0000000..07a2869 --- /dev/null +++ b/Services/DataBase/Classes/PdoDriver.php @@ -0,0 +1,217 @@ +_pdo !== null) { + return; + } + + $hosts = $this->config['hosts'] ?? [$this->config['host'] ?? 'localhost']; + $dbname = $this->config['dbname'] ?? ''; + $user = $this->config['user'] ?? ''; + $password = $this->config['password'] ?? ''; + $charset = $this->config['charset'] ?? 'utf8mb4'; + + $errors = []; + + foreach ($hosts as $host) { + $dsn = "mysql:host=$host;dbname=$dbname;charset=$charset"; + + try { + $pdo = new PDO($dsn, $user, $password, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + $pdo->setAttribute(PDO::ATTR_STATEMENT_CLASS, [Statement::class, [$this]]); + + $this->_pdo = $pdo; + + return; + } catch (PDOException $e) { + $errors[] = "$host: {$e->getMessage()}"; + } + } + + throw new MyException( + 'Не удалось подключиться ни к одному хосту БД: :errors', [':errors' => implode('; ', $errors)] + ); + } + + /** + * @return PDO Подключается при первом обращении + * @throws MyException + */ + public function pdo(): PDO + { + $this->connect(); + + return $this->_pdo; + } + + /** + * Готовит выражение (через Statement — см. PDO::ATTR_STATEMENT_CLASS). + * + * @param string $sql SQL-запрос + * @return false|PDOStatement + * @throws MyException + */ + public function prepare(string $sql): false|PDOStatement + { + return $this->pdo()->prepare($sql); + } + + /** + * Готовит, выполняет (с профилированием времени — см. ProfilerPDO) и возвращает выражение. + * + * @param string $sql SQL-запрос + * @param array $params Параметры для execute() + * @return false|PDOStatement + * @throws MyException + */ + public function query(string $sql, array $params = []): false|PDOStatement + { + $statement = $this->prepare($sql); + + ProfilerPDO::wrap($sql, $params, static fn () => $statement->execute($params)); + + return $statement; + } + + /** + * @param string $sql SQL-запрос + * @return int Число задетых строк + * @throws MyException + */ + public function exec(string $sql): int + { + return $this->pdo()->exec($sql); + } + + /** + * Начинает транзакцию; на вложенном уровне — SAVEPOINT. + * + * @return bool + * @throws MyException + */ + public function beginTransaction(): bool + { + $result = $this->_transaction_level === 0 + ? $this->pdo()->beginTransaction() + : (bool) $this->pdo()->exec('SAVEPOINT sp_' . $this->_transaction_level); + + $this->_transaction_level++; + + return $result; + } + + /** + * Фиксирует транзакцию; на вложенном уровне — RELEASE SAVEPOINT. + * + * @return bool + * @throws MyException + */ + public function commit(): bool + { + $this->_transaction_level--; + + return $this->_transaction_level === 0 + ? $this->pdo()->commit() + : (bool) $this->pdo()->exec('RELEASE SAVEPOINT sp_' . $this->_transaction_level); + } + + /** + * Откатывает транзакцию; на вложенном уровне — ROLLBACK TO SAVEPOINT. + * + * @return bool + * @throws MyException + */ + public function rollback(): bool + { + $this->_transaction_level--; + + return $this->_transaction_level === 0 + ? $this->pdo()->rollBack() + : (bool) $this->pdo()->exec('ROLLBACK TO SAVEPOINT sp_' . $this->_transaction_level); + } + + /** + * @return bool Идёт ли сейчас транзакция (на любом уровне вложенности) + */ + public function inTransaction(): bool + { + return $this->_transaction_level > 0; + } + + /** + * Выполняет $callback в транзакции: begin → callback → commit; + * при исключении — rollback и повторный throw. Вложенные вызовы (в том числе + * из разных Repository на одном PdoDriver) используют SAVEPOINT прозрачно. + * + * @param callable $callback + * @return mixed Результат $callback + * @throws MyException + */ + public function transaction(callable $callback): mixed + { + $this->beginTransaction(); + + try { + $result = $callback($this); + $this->commit(); + + return $result; + } catch (\Throwable $e) { + $this->rollback(); + + throw $e; + } + } + + /** + * @return string|false ID последней вставленной строки + * @throws MyException + */ + public function lastInsertId(): string|false + { + return $this->pdo()->lastInsertId(); + } +} diff --git a/Services/DataBase/Classes/Profiler.php b/Services/DataBase/Classes/Profiler.php new file mode 100644 index 0000000..f44fd7c --- /dev/null +++ b/Services/DataBase/Classes/Profiler.php @@ -0,0 +1,68 @@ + */ + private static array $_entries = []; + + /** + * Добавляет запись о выполненном запросе. + * + * @param string $sql SQL-запрос + * @param array $params Параметры запроса + * @param float $time_ms Время выполнения в миллисекундах + * @param string $driver Имя драйвера (для отчёта, если профилируются разные бэкенды) + * @return void + */ + public static function log(string $sql, array $params, float $time_ms, string $driver = 'pdo'): void + { + self::$_entries[] = ['sql' => $sql, 'params' => $params, 'time_ms' => $time_ms, 'driver' => $driver]; + } + + /** + * @return array + */ + public static function entries(): array + { + return self::$_entries; + } + + /** + * @return float Суммарное время всех залогированных запросов, мс + */ + public static function totalTime(): float + { + return array_sum(array_column(self::$_entries, 'time_ms')); + } + + /** + * @return int Число залогированных запросов + */ + public static function count(): int + { + return count(self::$_entries); + } + + /** + * Очищает накопленные записи. + * + * @return void + */ + public static function reset(): void + { + self::$_entries = []; + } +} diff --git a/Services/DataBase/Classes/ProfilerPDO.php b/Services/DataBase/Classes/ProfilerPDO.php new file mode 100644 index 0000000..030814c --- /dev/null +++ b/Services/DataBase/Classes/ProfilerPDO.php @@ -0,0 +1,34 @@ + Значения, забинженные через bindValue()/execute() */ + protected array $_bound_params = []; + + /** + * Конструктор вызывается только изнутри PDO (PDO::ATTR_STATEMENT_CLASS) — не создавать напрямую. + * + * @param PdoDriver $driver Владелец, нужен для quote() в showQuery() + */ + protected function __construct(protected PdoDriver $driver) + { + } + + /** + * @inheritDoc + */ + public function bindValue(int|string $param, mixed $value, int $type = PDO::PARAM_STR): bool + { + $this->_bound_params[$param] = $value; + + return parent::bindValue($param, $value, $type); + } + + /** + * @inheritDoc + */ + public function execute(?array $params = null): bool + { + foreach ($params ?? [] as $key => $value) { + $this->_bound_params[$key] = $value; + } + + return parent::execute($params); + } + + /** + * Возвращает SQL с подставленными вместо плейсхолдеров значениями — только для дебага + * (не для реального выполнения: значения квотируются через PDO::quote(), не защищают от всего, + * на что рассчитана честная подготовленная подстановка). + * + * @return string + * @throws \System\Classes\MyException + */ + public function showQuery(): string + { + $sql = $this->queryString; + + foreach ($this->_bound_params as $key => $value) { + $quoted = is_int($value) || is_float($value) ? (string) $value : $this->driver->pdo()->quote((string) $value); + + if (is_int($key)) { + $pos = strpos($sql, '?'); + + if ($pos !== false) { + $sql = substr_replace($sql, $quoted, $pos, 1); + } + } else { + $placeholder = str_starts_with((string) $key, ':') ? $key : ':' . $key; + $sql = str_replace($placeholder, $quoted, $sql); + } + } + + return $sql; + } + + /** + * Алиас showQuery() для быстрого дебага. + * + * @return string + * @throws \System\Classes\MyException + */ + public function sq(): string + { + return $this->showQuery(); + } +} diff --git a/Services/DataBase/Model.php b/Services/DataBase/Model.php new file mode 100644 index 0000000..d244886 --- /dev/null +++ b/Services/DataBase/Model.php @@ -0,0 +1,58 @@ +$name = $value; + } + + /** + * @param string $name Имя свойства + * @return mixed null, если свойства нет + */ + public function __get(string $name): mixed + { + return $this->$name ?? null; + } + + /** + * @param string $name Имя свойства + * @return bool + */ + public function __isset(string $name): bool + { + return isset($this->$name); + } + + /** + * @return array Все свойства + */ + public function toArray(): array + { + return (array) $this; + } +} diff --git a/Services/Database.php b/Services/Database.php new file mode 100644 index 0000000..ab146ef --- /dev/null +++ b/Services/Database.php @@ -0,0 +1,58 @@ +query('SELECT ...'); + * Database::instance('reporting')->query(...); // отдельное подключение + */ +class Database +{ + /** @var array Кэш подключений по имени */ + protected static array $_instances = []; + + /** + * Возвращает подключение по имени (кэшируется). + * + * @param string|null $name Имя подключения из конфига; null — 'default' + * @return PdoDriver + * @throws MyException Если подключение не описано в конфиге или драйвер неизвестен + */ + public static function instance(?string $name = null): PdoDriver + { + $name ??= 'default'; + + if (!isset(self::$_instances[$name])) { + $config = Config::get('db', $name); + + if ($config === null) { + throw new MyException('Подключение к БД не описано в конфиге: :name', [':name' => $name]); + } + + if (($config['driver'] ?? null) !== 'pdo') { + throw new MyException( + 'Неизвестный драйвер БД: :driver', [':driver' => $config['driver'] ?? 'null'] + ); + } + + self::$_instances[$name] = new PdoDriver($config['connection'] ?? []); + } + + return self::$_instances[$name]; + } +} diff --git a/Services/Elasticsearch.php b/Services/Elasticsearch.php new file mode 100644 index 0000000..e2846e6 --- /dev/null +++ b/Services/Elasticsearch.php @@ -0,0 +1,47 @@ +search('logs', ['query' => ['match_all' => new stdClass()]]); + */ +class Elasticsearch +{ + /** @var array Кэш клиентов по имени подключения */ + protected static array $_instances = []; + + /** + * @param string|null $name Имя подключения из конфига; null — 'default' + * @return Client + * @throws MyException Если подключение не описано в конфиге + */ + public static function instance(?string $name = null): Client + { + $name ??= 'default'; + + if (!isset(self::$_instances[$name])) { + $config = Config::get('elasticsearch', $name); + + if ($config === null) { + throw new MyException('Подключение к Elasticsearch не описано в конфиге: :name', [':name' => $name]); + } + + self::$_instances[$name] = new Client($config); + } + + return self::$_instances[$name]; + } +} diff --git a/Services/Elasticsearch/Client.php b/Services/Elasticsearch/Client.php new file mode 100644 index 0000000..9ddb1fa --- /dev/null +++ b/Services/Elasticsearch/Client.php @@ -0,0 +1,152 @@ +request($method, $path, $document)->json(); + } + + /** + * @param string $index Имя индекса + * @param string $id ID документа + * @return array|null null, если документ не найден (404) + * @throws \JsonException + */ + public function get(string $index, string $id): ?array + { + $response = $this->request('GET', "/$index/_doc/" . rawurlencode($id)); + + return $response->status() === 404 ? null : $response->json(); + } + + /** + * @param string $index Имя индекса + * @param string $id ID документа + * @return bool + */ + public function exists(string $index, string $id): bool + { + return $this->request('GET', "/$index/_doc/" . rawurlencode($id))->status() === 200; + } + + /** + * @param string $index Имя индекса + * @param array $query Elasticsearch Query DSL, например ['query' => ['match' => ['title' => 'foo']]] + * @return array Ответ Elasticsearch (декодированный JSON) + * @throws \JsonException + */ + public function search(string $index, array $query): array + { + return $this->request('POST', "/$index/_search", $query)->json(); + } + + /** + * @param string $index Имя индекса + * @param string $id ID документа + * @return bool true, если удалён + */ + public function delete(string $index, string $id): bool + { + return $this->request('DELETE', "/$index/_doc/" . rawurlencode($id))->isSuccess(); + } + + /** + * @param string $index Имя индекса + * @param array $body settings/mappings (опционально) + * @return array Ответ Elasticsearch (декодированный JSON) + * @throws \JsonException + */ + public function createIndex(string $index, array $body = []): array + { + return $this->request('PUT', "/$index", $body ?: null)->json(); + } + + /** + * @param string $index Имя индекса + * @return bool true, если удалён + */ + public function deleteIndex(string $index): bool + { + return $this->request('DELETE', "/$index")->isSuccess(); + } + + /** + * Проверяет доступность кластера. Не бросает исключение при недоступности — просто false + * (удобно для graceful skip в тестах/коде, когда Elasticsearch может быть не поднят). + * + * @return bool + */ + public function ping(): bool + { + try { + return $this->request('GET', '/')->isSuccess(); + } catch (RuntimeException) { + return false; + } + } + + /** + * @param string $method HTTP-метод + * @param string $path Путь относительно base_url + * @param array|null $body Тело запроса (JSON), null — без тела + * @return Response + */ + protected function request(string $method, string $path, ?array $body = null): Response + { + $http_request = HttpRequest::factory(rtrim($this->config['base_url'] ?? '', '/') . $path) + ->method($method) + ->timeout($this->config['timeout'] ?? 10); + + if ($body !== null) { + $http_request->json($body); + } + + if (!empty($this->config['username'])) { + $http_request->header( + 'Authorization', + 'Basic ' . base64_encode($this->config['username'] . ':' . ($this->config['password'] ?? '')) + ); + } + + return (new Curl())->execute($http_request); + } +} diff --git a/Services/Mongo.php b/Services/Mongo.php new file mode 100644 index 0000000..cb9b7a2 --- /dev/null +++ b/Services/Mongo.php @@ -0,0 +1,52 @@ +collection('logs')->insertOne([...]); + */ +class Mongo +{ + /** @var array Кэш подключений по имени */ + protected static array $_instances = []; + + /** + * @param string|null $name Имя подключения из конфига; null — 'default' + * @return MongoDriver + * @throws MyException Если подключение не описано в конфиге + */ + public static function instance(?string $name = null): MongoDriver + { + $name ??= 'default'; + + if (!isset(self::$_instances[$name])) { + $config = Config::get('mongo', $name); + + if ($config === null) { + throw new MyException('Подключение к MongoDB не описано в конфиге: :name', [':name' => $name]); + } + + self::$_instances[$name] = new MongoDriver($config); + } + + return self::$_instances[$name]; + } +} diff --git a/Services/Mongo/MongoDriver.php b/Services/Mongo/MongoDriver.php new file mode 100644 index 0000000..5d21c29 --- /dev/null +++ b/Services/Mongo/MongoDriver.php @@ -0,0 +1,67 @@ +_client = new Client($config['uri'] ?? 'mongodb://localhost:27017'); + $this->_database = $config['database'] ?? ''; + } + + /** + * @return Client + */ + public function client(): Client + { + return $this->_client; + } + + /** + * @param string|null $name null — имя БД из конфига + * @return Database + */ + public function database(?string $name = null): Database + { + return $this->_client->selectDatabase($name ?? $this->_database); + } + + /** + * @param string $name Имя коллекции + * @param string|null $database null — имя БД из конфига + * @return Collection + */ + public function collection(string $name, ?string $database = null): Collection + { + return $this->_client->selectCollection($database ?? $this->_database, $name); + } +} diff --git a/System/Classes/Controller.php b/System/Classes/Controller.php index c9e08c3..c3d611d 100644 --- a/System/Classes/Controller.php +++ b/System/Classes/Controller.php @@ -39,7 +39,7 @@ class Controller extends BaseController * 403 — при недостаточной роли). * * @return void - * @throws HTTPException 403, если CSRF-токен не прошёл или роль не подходит + * @throws HTTPException|MyException 403, если CSRF-токен не прошёл или роль не подходит */ protected function before(): void { @@ -70,9 +70,7 @@ class Controller extends BaseController } /** - * Рендерит шаблон контента внутри layout. - * Если $dir пуст — определяется автоматически из имени класса - * (App\Controller\FooController → view/Foo). + * Рендерит шаблон контента внутри layout вместе с верхним и боковым меню. * * @param string $template Имя шаблона контента без расширения * @param array $data Данные, передаваемые в шаблон @@ -81,35 +79,68 @@ class Controller extends BaseController * @throws MyException */ protected function render(string $template, array $data = [], string $dir = ''): string + { + return (new View($this->_layout, 'view', [ + 'menu_top' => $this->renderMenuTop(), + 'menu_side' => $this->renderMenuSide(), + 'content' => $this->renderContent($template, $data, $dir), + ]))->render(); + } + + /** + * Рендерит шаблон контента без layout — просто view, без меню и обвязки. + * Если $dir пуст — определяется автоматически из имени класса + * (App\Controller\FooController → view/Foo). Пригодится для ajax-фрагментов: + * экшен сам решает, вызывать render() или renderContent(), без проверки isAjax(). + * + * @param string $template Имя шаблона контента без расширения + * @param array $data Данные, передаваемые в шаблон + * @param string $dir Каталог шаблона относительно view/ (опционально) + * @return string Готовый HTML фрагмента + * @throws MyException + */ + protected function renderContent(string $template, array $data = [], string $dir = ''): string { if ($dir === '') { $class = substr(get_class($this), strlen('App\\Controller\\')); // [Admin\]FooController $dir = 'view/' . str_replace('\\', '/', substr($class, 0, -10)); // view/[Admin/]Foo } - return (new View($this->_layout, 'view', [ - 'content' => (new View($template, $dir, $data))->render(), - ] + $this->layoutData()))->render(); + return (new View($template, $dir, $data))->render(); } /** - * Дополнительные данные, передаваемые в layout помимо 'content'. - * По умолчанию — пункты главного меню; наследники могут переопределить - * полностью (как AdminController — своим боковым меню). + * HTML верхнего меню сайта — рендерит {@see menuTop()} через шаблон view/menu_top. * - * @return array + * @return string + * @throws MyException */ - protected function layoutData(): array + protected function renderMenuTop(): string { - return ['menu' => $this->menu()]; + return (new View('menu_top', 'view', ['menu' => $this->menuTop()]))->render(); } /** - * Пункты главного меню сайта. Активный пункт — по текущему URI. + * HTML бокового меню раздела — рендерит {@see menuSide()} через шаблон view/menu_side. + * Пустая строка, если пунктов нет (по умолчанию бокового меню нет вовсе; + * layout.html в этом случае не рисует колонку сайдбара). + * + * @return string + * @throws MyException + */ + protected function renderMenuSide(): string + { + $items = $this->menuSide(); + + return $items ? (new View('menu_side', 'view', ['menu' => $items]))->render() : ''; + } + + /** + * Пункты верхнего меню сайта. Активный пункт — по текущему URI. * * @return array */ - protected function menu(): array + protected function menuTop(): array { $uri = Request::$current?->uri() ?? ''; $user = Auth::instance()->getUser(); @@ -133,12 +164,36 @@ class Controller extends BaseController return $items; } + /** + * Пункты бокового меню — общие для всего сайта (как и menuTop()), видны + * на любой странице любому пользователю, независимо от роли и авторизации. + * Активный пункт — по текущему URI. + * + * @return array + */ + protected function menuSide(): array + { + $uri = Request::$current?->uri() ?? ''; + $items = [ + ['title' => 'Главная', 'url' => '/'], + ['title' => 'О нас', 'url' => '/about'], + ['title' => 'Новости', 'url' => '/news'], + ]; + + foreach ($items as &$item) { + $item['active'] = trim($item['url'], '/') === $uri; + } + + return $items; + } + /** * Формирует JSON-ответ: ставит статус и Content-Type, кодирует данные. * - * @param mixed $data Данные ответа + * @param mixed $data Данные ответа * @param int $status HTTP-статус * @return string JSON + * @throws \JsonException */ protected function json(mixed $data, int $status = 200): string { diff --git a/System/Classes/ProfilerToolbar.php b/System/Classes/ProfilerToolbar.php new file mode 100644 index 0000000..656205a --- /dev/null +++ b/System/Classes/ProfilerToolbar.php @@ -0,0 +1,91 @@ +getUser(); + + if (($user['role'] ?? null) !== 'admin') { + return ''; + } + + $time_ms = (microtime(true) - ($_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true))) * 1000; + $memory = memory_get_peak_usage(true); + $query_count = Profiler::count(); + $query_time = Profiler::totalTime(); + + $rows = ''; + foreach (Profiler::entries() as $entry) { + $rows .= '' . + '' . number_format($entry['time_ms'], 2) . ' ms' . + '' . htmlspecialchars($entry['sql'], ENT_QUOTES, 'UTF-8') . '' . + '' . htmlspecialchars(json_encode($entry['params'], JSON_UNESCAPED_UNICODE), ENT_QUOTES, 'UTF-8') . '' . + ''; + } + + return self::html($time_ms, $memory, $query_count, $query_time, $rows); + } + + /** + * @param float $time_ms Время выполнения запроса, мс + * @param int $memory Пиковое потребление памяти, байт + * @param int $query_count Число SQL-запросов + * @param float $query_time Суммарное время SQL-запросов, мс + * @param string $rows Готовые со списком запросов + * @return string + */ + private static function html(float $time_ms, int $memory, int $query_count, float $query_time, string $rows): string + { + $memory_mb = number_format($memory / 1024 / 1024, 2); + $time = number_format($time_ms, 1); + $query_ms = number_format($query_time, 1); + + return << +
+ Profiler + время: {$time} ms + память: {$memory_mb} MB + SQL: {$query_count} ({$query_ms} ms) +
+ +
+ HTML; + } +} diff --git a/System/Classes/Repository.php b/System/Classes/Repository.php new file mode 100644 index 0000000..9dcac62 --- /dev/null +++ b/System/Classes/Repository.php @@ -0,0 +1,325 @@ +pdo (PdoDriver, не сырой \PDO — но с тем же query()/prepare()/... + * API, включая fetch-методы через Statement). + */ +abstract class Repository +{ + /** @var string Имя первичного ключа */ + public string $primary_col = 'id'; + + /** @var bool Первичный ключ — автоинкремент (влияет на processData()/create()) */ + public bool $is_auto_increment = true; + + /** @var PdoDriver Подключение */ + protected PdoDriver $pdo; + + /** @var string Таблица, с которой работает репозиторий */ + protected string $table; + + /** @var class-string Класс, который должен возвращаться из get()/getItemWhere() */ + private string $obj_class; + + /** + * @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->obj_class = $obj_class; + } + + /** + * Начинает транзакцию; на вложенном уровне — SAVEPOINT (см. PdoDriver — он же считает + * вложенность: у него общий счётчик на всё подключение, а не свой на каждый Repository, + * поэтому несколько репозиториев на одном Database::instance() безопасно участвуют + * в одной внешней транзакции). + * + * @return bool + * @throws MyException + */ + public function beginTransaction(): bool + { + return $this->pdo->beginTransaction(); + } + + /** + * Фиксирует транзакцию; на вложенном уровне — RELEASE SAVEPOINT. + * + * @return bool + * @throws MyException + */ + public function commit(): bool + { + return $this->pdo->commit(); + } + + /** + * Откатывает транзакцию; на вложенном уровне — ROLLBACK TO SAVEPOINT. + * + * @return bool + * @throws MyException + */ + public function rollBack(): bool + { + return $this->pdo->rollback(); + } + + /** + * Выполняет $callback в транзакции: begin → callback → commit; + * при исключении — rollBack и повторный throw. + * + * @param callable $callback + * @return mixed + * @throws MyException + */ + public function transaction(callable $callback): mixed + { + $this->beginTransaction(); + + try { + $result = $callback($this); + $this->commit(); + + return $result; + } catch (Throwable $e) { + $this->rollBack(); + + throw $e; + } + } + + /** + * Получить 1 запись по первичному ключу. + * + * @param int|string $id + * @return T|false + * @throws MyException + */ + public function get(int|string $id): mixed + { + $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(); + + return $stmt->fetchObject($this->obj_class); + } + + /** + * Получить 1 запись по произвольному условию (без плейсхолдеров — $where подставляется в SQL как есть). + * + * @param string $where SQL-условие после WHERE + * @return T|false|null null, если $where пуст + * @throws MyException + */ + public function getItemWhere(string $where): mixed + { + if ($where === '') { + return null; + } + + $sql = "SELECT * FROM $this->table WHERE $where"; + + return $this->pdo->query($sql)->fetchObject($this->obj_class); + } + + /** + * Получить список. + * + * @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_* + * @return bool|T[] + * @throws MyException + */ + public function getList( + string $cols, array $where = [], string $order = '', string $sort = 'ASC', int $fetch = PDO::FETCH_KEY_PAIR + ): bool|array { + $str = ''; + + if (!empty($where)) { + $conditions = []; + + foreach ($where as $key => $value) { + if (is_string($value) && (strtoupper($value) === 'IS NOT NULL' || strtoupper($value) === 'IS NULL')) { + $conditions[] = "$key $value"; + unset($where[$key]); + } else { + $conditions[] = "$key = :$key"; + } + } + + $str = 'AND ' . implode(' AND ', $conditions); + } + + $sql = "SELECT $cols FROM $this->table WHERE 1 $str"; + + if ($order !== '') { + $sql .= " ORDER BY $order $sort"; + } + + $stmt = $this->pdo->prepare($sql); + $stmt->execute($where); + + return $stmt->fetchAll($fetch); + } + + /** + * Создать запись. + * + * @param array $data + * @return int lastInsertId + * @throws MyException + */ + public function create(array $data): int + { + $create_data = $this->processData($data); + + $fields = implode(',', $create_data['fields']); + $keys = implode(',', $create_data['keys']); + + $sql = "INSERT INTO $this->table ($fields) VALUES ($keys)"; + $stmt = $this->pdo->prepare($sql); + + foreach ($create_data['bind'] as $param) { + $stmt->bindValue(...$param); + } + + $stmt->execute(); + + return (int) $this->pdo->lastInsertId(); + } + + /** + * Изменить запись по $primary_col. + * + * @param array $data [$primary_col обязателен] + * @return int Число задетых строк + * @throws MyException + */ + public function update(array $data): int + { + if (!isset($data[$this->primary_col])) { + throw new MyException('Массив не содержит :key', [':key' => $this->primary_col]); + } + + $update_data = $this->processData($data); + + $set = implode(', ', $update_data['set']); + $sql = "UPDATE $this->table SET $set WHERE $this->primary_col = :id"; + + $stmt = $this->pdo->prepare($sql); + $stmt->bindValue(':id', $data[$this->primary_col], is_int($data[$this->primary_col]) ? PDO::PARAM_INT : PDO::PARAM_STR); + + foreach ($update_data['bind'] as $param) { + $stmt->bindValue(...$param); + } + + $stmt->execute(); + + return $stmt->rowCount(); + } + + /** + * Удалить запись по $primary_col. + * + * @param int|string $id + * @return bool + * @throws MyException + */ + public function delete(int|string $id): bool + { + $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); + + return $stmt->execute(); + } + + /** + * Удалить записи по произвольному условию. + * + * @param string $where SQL-условие после WHERE (с плейсхолдерами) + * @param array $params Параметры для плейсхолдеров + * @return bool + * @throws MyException + */ + public function deleteWhere(string $where, array $params): bool + { + $sql = "DELETE FROM $this->table WHERE $where"; + + return $this->pdo->prepare($sql)->execute($params); + } + + /** + * Обработать данные для create()/update(): собрать fields/keys (INSERT) или set (UPDATE) + * и bind-параметры с определением типа PDO::PARAM_* по значению. $primary_col пропускается, + * если is_auto_increment (в create()); в set попадает, если явно присутствует в $data. + * + * @param array $data + * @return array{fields?:array,keys?:array,set?:array,bind:array} + */ + protected function processData(array $data): array + { + $result = ['bind' => []]; + + foreach ($data as $key => $value) { + if ($this->is_auto_increment && $key === $this->primary_col) { + continue; + } + + if (array_key_exists($this->primary_col, $data)) { + $result['set'][] = "`$key` = :$key"; + } + + $result['fields'][] = "`$key`"; + $result['keys'][] = ":$key"; + + if (is_int($value)) { + $result['bind'][] = [":$key", $value, PDO::PARAM_INT]; + } elseif (is_string($value) || is_float($value)) { + $result['bind'][] = [":$key", $value, PDO::PARAM_STR]; + } elseif (is_bool($value)) { + $result['bind'][] = [":$key", $value, PDO::PARAM_BOOL]; + } elseif ($value === null) { + $result['bind'][] = [":$key", $value, PDO::PARAM_NULL]; + } elseif (is_array($value)) { + try { + $result['bind'][] = [":$key", json_encode($value, JSON_THROW_ON_ERROR), PDO::PARAM_STR]; + } catch (JsonException) { + $result['bind'][] = [":$key", null, PDO::PARAM_STR]; + } + } + } + + return $result; + } +} diff --git a/System/Classes/View.php b/System/Classes/View.php index de57c9b..3fc6c68 100644 --- a/System/Classes/View.php +++ b/System/Classes/View.php @@ -25,6 +25,22 @@ class View */ public array $_data = []; + /** + * @var array Зарегистрированные пути CSS-файлов — общие для всех View за + * один запрос (content рендерится раньше layout, см. Controller::render()) + */ + private static array $_styles = []; + + /** + * @var array Зарегистрированные JS-файлы + */ + private static array $_scripts = []; + + /** + * @var string|null Путь к зарегистрированному manifest-файлу + */ + private static ?string $_manifest = null; + /** * @param string|null $file Имя файла шаблона без расширения @@ -95,4 +111,100 @@ class View ); } } + + /** + * Регистрирует CSS-файл из App/media/css — вызывается прямо из шаблона: + * setStyle('about.css'); ?>. Выводится позже через getStyles() + * (обычно из layout.html, после того как content уже отрендерен). + * + * @param string $file Имя файла с расширением, например 'about.css' + * @return void + */ + public function setStyle(string $file): void + { + $name = pathinfo($file, PATHINFO_FILENAME); + + if (Core::findFile('media/css', $name, 'css') === false) { + echo ''; + + return; + } + + self::$_styles[] = "/App/media/css/$name.css"; + } + + /** + * Регистрирует JS-файл из App/media/js — вызывается прямо из шаблона: + * setScript('about.js'); ?>. Выводится позже через getScripts(). + * + * @param string $file Имя файла с расширением, например 'about.js' + * @param string $type Атрибут type у \n"; + } + } } diff --git a/composer.json b/composer.json index d53f2c0..2bc0d0e 100644 --- a/composer.json +++ b/composer.json @@ -2,6 +2,7 @@ "require": { "php": ">=8.2", "ext-pdo": "*", + "ext-pdo_mysql": "*", "phpoffice/phpspreadsheet": "5.*", "phpoffice/phpword": "1.1.0", "phpmailer/phpmailer": "v6.9.*", @@ -10,7 +11,8 @@ "select2/select2": "4.1.*", "components/jquery": "v3.7.1", "components/jqueryui": "1.12.*", - "dompdf/dompdf": "v3.1.*" + "dompdf/dompdf": "v3.1.*", + "mongodb/mongodb": "^2.3" }, "require-dev": { "phpunit/phpunit": "^11.0" @@ -31,6 +33,9 @@ "allow-plugins": { "php-http/discovery": true, "composer/installers": true + }, + "platform": { + "ext-mongodb": "2.3" } } -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index d35090a..6f5e1f0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d15667ff3f0882b8e8daa05eae634821", + "content-hash": "d9e5b024bdd073ae913e3de7dc02e71e", "packages": [ { "name": "components/jquery", @@ -146,28 +146,29 @@ }, { "name": "composer/pcre", - "version": "3.3.2", + "version": "3.4.0", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", "shasum": "" }, "require": { "php": "^7.4 || ^8.0" }, "conflict": { - "phpstan/phpstan": "<1.11.10" + "phpstan/phpstan": "<2.2.2" }, "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-strict-rules": "^1 || ^2", - "phpunit/phpunit": "^8 || ^9" + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" }, "type": "library", "extra": { @@ -205,7 +206,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.2" + "source": "https://github.com/composer/pcre/tree/3.4.0" }, "funding": [ { @@ -215,26 +216,22 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2024-11-12T16:29:46+00:00" + "time": "2026-06-07T11:47:49+00:00" }, { "name": "dompdf/dompdf", - "version": "v3.1.5", + "version": "v3.1.6", "source": { "type": "git", "url": "https://github.com/dompdf/dompdf.git", - "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", - "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/6d4b4eb8500f7a786da8868ba463a71b725a4005", + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005", "shasum": "" }, "require": { @@ -283,9 +280,9 @@ "homepage": "https://github.com/dompdf/dompdf", "support": { "issues": "https://github.com/dompdf/dompdf/issues", - "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" + "source": "https://github.com/dompdf/dompdf/tree/v3.1.6" }, - "time": "2026-03-03T13:54:37+00:00" + "time": "2026-07-20T12:29:38+00:00" }, { "name": "dompdf/php-font-lib", @@ -626,16 +623,16 @@ }, { "name": "masterminds/html5", - "version": "2.10.0", + "version": "2.10.1", "source": { "type": "git", "url": "https://github.com/Masterminds/html5-php.git", - "reference": "fcf91eb64359852f00d921887b219479b4f21251" + "reference": "fd5018f6815fff903946d0564977b44ce8010e29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", - "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29", "shasum": "" }, "require": { @@ -643,7 +640,7 @@ "php": ">=5.3.0" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10" }, "type": "library", "extra": { @@ -687,9 +684,82 @@ ], "support": { "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + "source": "https://github.com/Masterminds/html5-php/tree/2.10.1" }, - "time": "2025-07-25T09:04:22+00:00" + "time": "2026-06-23T18:43:15+00:00" + }, + { + "name": "mongodb/mongodb", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/mongodb/mongo-php-library", + "reference": "12e56461166d915e3dd8e4969ad0940ed5207d88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/12e56461166d915e3dd8e4969ad0940ed5207d88", + "reference": "12e56461166d915e3dd8e4969ad0940ed5207d88", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.0", + "ext-mongodb": "^2.3", + "php": "^8.1", + "psr/log": "^1.1.4|^2|^3", + "symfony/polyfill-php85": "^1.33" + }, + "replace": { + "mongodb/builder": "*" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0", + "phpunit/phpunit": "^10.5.35", + "rector/rector": "^2.3.4", + "squizlabs/php_codesniffer": "^3.7", + "vimeo/psalm": "~6.14.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "MongoDB\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Andreas Braun", + "email": "andreas.braun@mongodb.com" + }, + { + "name": "Jeremy Mikola", + "email": "jmikola@gmail.com" + }, + { + "name": "Jérôme Tamarelle", + "email": "jerome.tamarelle@mongodb.com" + } + ], + "description": "MongoDB driver library", + "homepage": "https://jira.mongodb.org/browse/PHPLIB", + "keywords": [ + "database", + "driver", + "mongodb", + "persistence" + ], + "time": "2026-04-28T12:50:06+00:00" }, { "name": "phpmailer/phpmailer", @@ -774,16 +844,16 @@ }, { "name": "phpoffice/phpspreadsheet", - "version": "5.7.0", + "version": "5.9.0", "source": { "type": "git", "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", - "reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8" + "reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8", - "reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/05e99ebf61238a70227b4d9cc02d0030d34f6339", + "reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339", "shasum": "" }, "require": { @@ -805,7 +875,7 @@ "maennchen/zipstream-php": "^2.1 || ^3.0", "markbaker/complex": "^3.0", "markbaker/matrix": "^3.0", - "php": "^8.1", + "php": "^8.2", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" }, "require-dev": { @@ -819,7 +889,7 @@ "phpstan/phpstan": "^1.1 || ^2.0", "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0", "phpstan/phpstan-phpunit": "^1.0 || ^2.0", - "phpunit/phpunit": "^10.5", + "phpunit/phpunit": "^10.5 || ^11.0", "squizlabs/php_codesniffer": "^3.7", "tecnickcom/tcpdf": "^6.5" }, @@ -877,9 +947,9 @@ ], "support": { "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", - "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.7.0" + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.9.0" }, - "time": "2026-04-20T02:42:17+00:00" + "time": "2026-07-12T19:17:39+00:00" }, { "name": "phpoffice/phpword", @@ -989,6 +1059,56 @@ }, "time": "2023-05-30T07:59:14+00:00" }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, { "name": "psr/simple-cache", "version": "3.0.0", @@ -1042,16 +1162,16 @@ }, { "name": "sabberworm/php-css-parser", - "version": "v9.3.0", + "version": "v9.4.0", "source": { "type": "git", "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", - "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949" + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", - "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", "shasum": "" }, "require": { @@ -1062,15 +1182,15 @@ "require-dev": { "php-parallel-lint/php-parallel-lint": "1.4.0", "phpstan/extension-installer": "1.4.3", - "phpstan/phpstan": "1.12.32 || 2.1.32", - "phpstan/phpstan-phpunit": "1.4.2 || 2.0.8", - "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7", + "phpstan/phpstan": "1.12.33 || 2.2.2", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.16", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11", "phpunit/phpunit": "8.5.52", "rawr/phpunit-data-provider": "3.3.1", - "rector/rector": "1.2.10 || 2.2.8", - "rector/type-perfect": "1.0.0 || 2.1.0", + "rector/rector": "1.2.10 || 2.4.6", + "rector/type-perfect": "1.0.0 || 2.1.3", "squizlabs/php_codesniffer": "4.0.1", - "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1" + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3" }, "suggest": { "ext-mbstring": "for parsing UTF-8 CSS" @@ -1078,7 +1198,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "9.4.x-dev" + "dev-main": "9.5.x-dev" } }, "autoload": { @@ -1116,9 +1236,9 @@ ], "support": { "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", - "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0" + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0" }, - "time": "2026-03-03T17:31:43+00:00" + "time": "2026-06-18T15:10:53+00:00" }, { "name": "select2/select2", @@ -1172,6 +1292,86 @@ ], "time": "2026-05-26T20:03:12+00:00" }, + { + "name": "symfony/polyfill-php85", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, { "name": "thecodingmachine/safe", "version": "v3.4.0", @@ -1468,20 +1668,19 @@ }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -1520,9 +1719,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "phar-io/manifest", @@ -1991,24 +2190,24 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.55", + "version": "11.5.56", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", - "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5f83edffa6967c3db468d48a695ec7bcb02e9256", + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256", "shasum": "" }, "require": { "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", @@ -2073,31 +2272,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.56" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-02-18T12:37:06+00:00" + "time": "2026-07-06T14:52:39+00:00" }, { "name": "sebastian/cli-parser", @@ -3195,8 +3378,12 @@ "prefer-lowest": false, "platform": { "php": ">=8.2", - "ext-pdo": "*" + "ext-pdo": "*", + "ext-pdo_mysql": "*" }, "platform-dev": {}, + "platform-overrides": { + "ext-mongodb": "2.3" + }, "plugin-api-version": "2.9.0" } diff --git a/tests/Unit/DatabaseTest.php b/tests/Unit/DatabaseTest.php new file mode 100644 index 0000000..1e17e39 --- /dev/null +++ b/tests/Unit/DatabaseTest.php @@ -0,0 +1,61 @@ +getProperty('_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 testDefaultConnectionReturnsPdoDriver(): void + { + $this->assertInstanceOf(PdoDriver::class, Database::instance()); + } + + public function testInstanceIsCachedPerConnectionName(): void + { + $this->assertSame(Database::instance('default'), Database::instance('default')); + } + + public function testDifferentConnectionNamesAreDistinctInstances(): void + { + Config::set('db', [ + 'default' => ['driver' => 'pdo', 'connection' => ['host' => 'db', 'dbname' => 'a']], + 'second' => ['driver' => 'pdo', 'connection' => ['host' => 'db', 'dbname' => 'b']], + ]); + + $this->assertNotSame(Database::instance('default'), Database::instance('second')); + } + + public function testUnknownConnectionNameThrows(): void + { + $this->expectException(MyException::class); + Database::instance('nonexistent'); + } + + public function testUnknownDriverThrows(): void + { + Config::set('db', ['bad' => ['driver' => 'mysqli', 'connection' => []]]); + + $this->expectException(MyException::class); + Database::instance('bad'); + } +} diff --git a/tests/Unit/ElasticsearchClientTest.php b/tests/Unit/ElasticsearchClientTest.php new file mode 100644 index 0000000..29491b2 --- /dev/null +++ b/tests/Unit/ElasticsearchClientTest.php @@ -0,0 +1,59 @@ +client = new Client(Config::get('elasticsearch', 'default') ?? []); + + if (!$this->client->ping()) { + $this->markTestSkipped('Elasticsearch недоступен (контейнер не поднят) — см. docker-dev/docker-compose.yml'); + } + + $this->client->deleteIndex('bicycle_test_index'); + } + + protected function tearDown(): void + { + // ping() ещё раз — если setUp() уже пропустил тест через markTestSkipped(), + // PHPUnit всё равно вызывает tearDown(), и deleteIndex() упадёт на реальном подключении. + if (isset($this->client) && $this->client->ping()) { + $this->client->deleteIndex('bicycle_test_index'); + } + } + + public function testIndexAndGetDocument(): void + { + $this->client->index('bicycle_test_index', ['title' => 'Bicycle'], 'doc1'); + $document = $this->client->get('bicycle_test_index', 'doc1'); + + $this->assertSame('Bicycle', $document['_source']['title']); + } + + public function testGetMissingDocumentReturnsNull(): void + { + $this->assertNull($this->client->get('bicycle_test_index', 'nonexistent')); + } + + public function testDeleteDocument(): void + { + $this->client->index('bicycle_test_index', ['title' => 'Bicycle'], 'doc1'); + $this->assertTrue($this->client->delete('bicycle_test_index', 'doc1')); + $this->assertNull($this->client->get('bicycle_test_index', 'doc1')); + } + + public function testExists(): void + { + $this->client->index('bicycle_test_index', ['title' => 'Bicycle'], 'doc1'); + $this->assertTrue($this->client->exists('bicycle_test_index', 'doc1')); + $this->assertFalse($this->client->exists('bicycle_test_index', 'nonexistent')); + } +} diff --git a/tests/Unit/ElasticsearchTest.php b/tests/Unit/ElasticsearchTest.php new file mode 100644 index 0000000..9d9bc42 --- /dev/null +++ b/tests/Unit/ElasticsearchTest.php @@ -0,0 +1,42 @@ +getProperty('_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 testDefaultConnectionReturnsClient(): void + { + $this->assertInstanceOf(Client::class, Elasticsearch::instance()); + } + + public function testInstanceIsCachedPerConnectionName(): void + { + $this->assertSame(Elasticsearch::instance('default'), Elasticsearch::instance('default')); + } + + public function testUnknownConnectionNameThrows(): void + { + $this->expectException(MyException::class); + Elasticsearch::instance('nonexistent'); + } +} diff --git a/tests/Unit/ModelTest.php b/tests/Unit/ModelTest.php new file mode 100644 index 0000000..3ba97c0 --- /dev/null +++ b/tests/Unit/ModelTest.php @@ -0,0 +1,64 @@ +name = 'bicycle'; + + $this->assertSame('bicycle', $model->name); + } + + public function testGetMissingAttributeReturnsNull(): void + { + $model = new Model(); + $this->assertNull($model->missing); + } + + public function testSetWritesAttribute(): void + { + $model = new Model(); + $model->name = 'bicycle'; + $this->assertSame('bicycle', $model->name); + } + + public function testIsset(): void + { + $model = new Model(); + $model->name = 'bicycle'; + + $this->assertTrue(isset($model->name)); + $this->assertFalse(isset($model->missing)); + } + + public function testToArray(): void + { + $model = new Model(); + $model->id = 1; + $model->name = 'bicycle'; + + $this->assertSame(['id' => 1, 'name' => 'bicycle'], $model->toArray()); + } + + public function testPdoFetchClassPopulatesRealProperties(): void + { + // PDO::FETCH_CLASS выставляет свойства напрямую, минуя __set() — проверяем именно это, + // не общий __get/__set (см. PHP-документацию по FETCH_CLASS: свойства до конструктора). + $pdo = new PDO('sqlite::memory:'); + $pdo->exec('CREATE TABLE t (id INTEGER, name TEXT)'); + $pdo->exec("INSERT INTO t (id, name) VALUES (1, 'bicycle')"); + + $model = $pdo->query('SELECT * FROM t')->fetchObject(Model::class); + + $this->assertInstanceOf(Model::class, $model); + $this->assertSame('bicycle', $model->name); + $this->assertSame(['id' => 1, 'name' => 'bicycle'], $model->toArray()); + } +} diff --git a/tests/Unit/MongoDriverTest.php b/tests/Unit/MongoDriverTest.php new file mode 100644 index 0000000..73ce3ef --- /dev/null +++ b/tests/Unit/MongoDriverTest.php @@ -0,0 +1,53 @@ +markTestSkipped('ext-mongodb не установлен (см. docker/php-apache/8.2.8/Dockerfile)'); + } + } + + protected function tearDown(): void + { + $ref = new ReflectionClass(Mongo::class); + $prop = $ref->getProperty('_instances'); + $prop->setAccessible(true); + $prop->setValue(null, []); + } + + public function testInstanceReturnsMongoDriver(): void + { + $this->assertInstanceOf(MongoDriver::class, Mongo::instance()); + } + + public function testInstanceIsCachedPerConnectionName(): void + { + $this->assertSame(Mongo::instance('default'), Mongo::instance('default')); + } + + public function testCollectionReturnsMongoCollection(): void + { + $config = Config::get('mongo', 'default'); + $driver = new MongoDriver($config); + + $this->assertInstanceOf(\MongoDB\Collection::class, $driver->collection('bicycle_test_collection')); + } + + public function testDatabaseReturnsMongoDatabase(): void + { + $config = Config::get('mongo', 'default'); + $driver = new MongoDriver($config); + + $this->assertInstanceOf(\MongoDB\Database::class, $driver->database()); + } +} diff --git a/tests/Unit/MongoTest.php b/tests/Unit/MongoTest.php new file mode 100644 index 0000000..cf69e2d --- /dev/null +++ b/tests/Unit/MongoTest.php @@ -0,0 +1,34 @@ +getProperty('_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 testUnknownConnectionNameThrows(): void + { + $this->expectException(MyException::class); + Mongo::instance('nonexistent'); + } + + // instance()/кэширование по имени с реальным MongoDriver — см. MongoDriverTest + // (там же markTestSkipped без ext-mongodb: сам MongoDriver::__construct() требует расширение). +} diff --git a/tests/Unit/PdoDriverTest.php b/tests/Unit/PdoDriverTest.php new file mode 100644 index 0000000..71f3bf5 --- /dev/null +++ b/tests/Unit/PdoDriverTest.php @@ -0,0 +1,133 @@ +driver = new PdoDriver($config); + + try { + $this->driver->pdo(); + } catch (MyException $e) { + $this->markTestSkipped('Нет живого подключения к MariaDB (заполните App/config/config.local.php): ' . $e->getMessage()); + } + + $this->driver->exec('DROP TABLE IF EXISTS pdo_driver_test'); + $this->driver->exec('CREATE TABLE pdo_driver_test (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255))'); + } + + protected function tearDown(): void + { + Profiler::reset(); + + if (isset($this->driver)) { + try { + $this->driver->exec('DROP TABLE IF EXISTS pdo_driver_test'); + } catch (MyException) { + // соединения не было — нечего дропать + } + } + } + + public function testFailoverThrowsWithAllHostErrors(): void + { + // Чисто логика failover — не требует живой БД, оба хоста заведомо недоступны + $driver = new PdoDriver(['hosts' => ['bad-host-1', 'bad-host-2'], 'dbname' => 'x', 'user' => 'x', 'password' => 'x']); + + try { + $driver->pdo(); + $this->fail('Ожидалось MyException'); + } catch (MyException $e) { + $this->assertStringContainsString('bad-host-1', $e->getMessage()); + $this->assertStringContainsString('bad-host-2', $e->getMessage()); + } + } + + public function testQueryInsertsAndSelects(): void + { + $this->driver->query('INSERT INTO pdo_driver_test (name) VALUES (?)', ['foo']); + $row = $this->driver->query('SELECT * FROM pdo_driver_test WHERE name = ?', ['foo'])->fetch(); + + $this->assertSame('foo', $row['name']); + } + + public function testLastInsertId(): void + { + $this->driver->query('INSERT INTO pdo_driver_test (name) VALUES (?)', ['foo']); + $this->assertNotFalse($this->driver->lastInsertId()); + } + + public function testCommitPersistsChanges(): void + { + $this->driver->beginTransaction(); + $this->driver->query('INSERT INTO pdo_driver_test (name) VALUES (?)', ['committed']); + $this->driver->commit(); + + $row = $this->driver->query('SELECT * FROM pdo_driver_test WHERE name = ?', ['committed'])->fetch(); + $this->assertNotFalse($row); + } + + public function testRollbackDiscardsChanges(): void + { + $this->driver->beginTransaction(); + $this->driver->query('INSERT INTO pdo_driver_test (name) VALUES (?)', ['rolled_back']); + $this->driver->rollback(); + + $row = $this->driver->query('SELECT * FROM pdo_driver_test WHERE name = ?', ['rolled_back'])->fetch(); + $this->assertFalse($row); + } + + public function testNestedTransactionRollbackDoesNotAffectOuter(): void + { + $this->driver->beginTransaction(); + $this->driver->query('INSERT INTO pdo_driver_test (name) VALUES (?)', ['outer']); + + $this->driver->beginTransaction(); // SAVEPOINT sp_1 + $this->driver->query('INSERT INTO pdo_driver_test (name) VALUES (?)', ['inner']); + $this->driver->rollback(); // ROLLBACK TO SAVEPOINT sp_1 — не трогает 'outer' + + $this->driver->commit(); // фиксирует внешнюю транзакцию + + $outer = $this->driver->query('SELECT * FROM pdo_driver_test WHERE name = ?', ['outer'])->fetch(); + $inner = $this->driver->query('SELECT * FROM pdo_driver_test WHERE name = ?', ['inner'])->fetch(); + + $this->assertNotFalse($outer); + $this->assertFalse($inner); + } + + public function testTransactionHelperRollsBackOnException(): void + { + try { + $this->driver->transaction(function (PdoDriver $driver) { + $driver->query('INSERT INTO pdo_driver_test (name) VALUES (?)', ['should_rollback']); + throw new \RuntimeException('boom'); + }); + $this->fail('Ожидалось RuntimeException'); + } catch (\RuntimeException) { + // ожидаемо + } + + $row = $this->driver->query('SELECT * FROM pdo_driver_test WHERE name = ?', ['should_rollback'])->fetch(); + $this->assertFalse($row); + } + + public function testInTransaction(): void + { + $this->assertFalse($this->driver->inTransaction()); + $this->driver->beginTransaction(); + $this->assertTrue($this->driver->inTransaction()); + $this->driver->commit(); + $this->assertFalse($this->driver->inTransaction()); + } +} diff --git a/tests/Unit/ProfilerTest.php b/tests/Unit/ProfilerTest.php new file mode 100644 index 0000000..76540e2 --- /dev/null +++ b/tests/Unit/ProfilerTest.php @@ -0,0 +1,60 @@ +assertCount(1, $entries); + $this->assertSame('SELECT 1', $entries[0]['sql']); + $this->assertSame(1.5, $entries[0]['time_ms']); + $this->assertSame('pdo', $entries[0]['driver']); + } + + public function testCountAndTotalTime(): void + { + Profiler::log('SELECT 1', [], 1.0); + Profiler::log('SELECT 2', [], 2.5); + + $this->assertSame(2, Profiler::count()); + $this->assertSame(3.5, Profiler::totalTime()); + } + + public function testResetClearsEntries(): void + { + Profiler::log('SELECT 1', [], 1.0); + Profiler::reset(); + + $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 new file mode 100644 index 0000000..19f22eb --- /dev/null +++ b/tests/Unit/ProfilerToolbarTest.php @@ -0,0 +1,82 @@ +users_file = sys_get_temp_dir() . '/bicycle_toolbar_users_' . uniqid() . '.php'; + file_put_contents($this->users_file, ' ['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 + { + 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 testReturnsEmptyStringForGuest(): void + { + $this->assertSame('', ProfilerToolbar::render()); + } + + public function testReturnsEmptyStringForNonAdminRole(): void + { + Session::instance()->set('auth_user', 'bob'); + + $this->assertSame('', ProfilerToolbar::render()); + } + + public function testRendersPanelForAdmin(): void + { + Session::instance()->set('auth_user', 'admin'); + + $html = ProfilerToolbar::render(); + + $this->assertStringContainsString('Profiler', $html); + $this->assertStringContainsString('SQL', $html); + } + + public function testIncludesProfilerEntries(): void + { + Session::instance()->set('auth_user', 'admin'); + Profiler::log('SELECT * FROM users WHERE id = ?', [42], 1.23); + + $html = ProfilerToolbar::render(); + + $this->assertStringContainsString('SELECT * FROM users WHERE id = ?', $html); + $this->assertStringContainsString('42', $html); + } +} diff --git a/tests/Unit/RepositoryTest.php b/tests/Unit/RepositoryTest.php new file mode 100644 index 0000000..0e314fd --- /dev/null +++ b/tests/Unit/RepositoryTest.php @@ -0,0 +1,157 @@ +driver = new PdoDriver($config); + + try { + $this->driver->pdo(); + } catch (MyException $e) { + $this->markTestSkipped('Нет живого подключения к MariaDB (заполните App/config/config.local.php): ' . $e->getMessage()); + } + + $this->driver->exec('DROP TABLE IF EXISTS repository_test_items'); + $this->driver->exec('CREATE TABLE repository_test_items (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255), qty INT)'); + + $this->repository = new RepositoryTestItemRepository(); + } + + protected function tearDown(): void + { + Profiler::reset(); + + if (isset($this->driver)) { + try { + $this->driver->exec('DROP TABLE IF EXISTS repository_test_items'); + } catch (MyException) { + } + } + + // Database::instance() кэширует подключение статически — сбрасываем между тестами + $ref = new ReflectionClass(Database::class); + $prop = $ref->getProperty('_instances'); + $prop->setAccessible(true); + $prop->setValue(null, []); + } + + public function testCreateAndGet(): void + { + $id = $this->repository->create(['name' => 'foo', 'qty' => 3]); + $row = $this->repository->get($id); + + $this->assertSame('foo', $row->name); + $this->assertSame(3, (int) $row->qty); + } + + public function testGetMissingReturnsFalse(): void + { + $this->assertFalse($this->repository->get(999999)); + } + + public function testGetListWithWhere(): void + { + $this->repository->create(['name' => 'a', 'qty' => 1]); + $this->repository->create(['name' => 'b', 'qty' => 2]); + $this->repository->create(['name' => 'a', 'qty' => 5]); + + $rows = $this->repository->getList('*', ['name' => 'a']); + + $this->assertCount(2, $rows); + } + + public function testGetItemWhere(): void + { + $this->repository->create(['name' => 'foo', 'qty' => 1]); + $row = $this->repository->getItemWhere("name = 'foo'"); + + $this->assertSame('foo', $row->name); + } + + public function testGetItemWhereEmptyReturnsNull(): void + { + $this->assertNull($this->repository->getItemWhere('')); + } + + public function testUpdate(): void + { + $id = $this->repository->create(['name' => 'foo', 'qty' => 1]); + $affected = $this->repository->update(['id' => $id, 'qty' => 10]); + + $this->assertSame(1, $affected); + $this->assertSame(10, (int) $this->repository->get($id)->qty); + } + + public function testUpdateWithoutPrimaryKeyThrows(): void + { + $this->expectException(MyException::class); + $this->repository->update(['qty' => 10]); + } + + public function testDelete(): void + { + $id = $this->repository->create(['name' => 'foo', 'qty' => 1]); + + $this->assertTrue($this->repository->delete($id)); + $this->assertFalse($this->repository->get($id)); + } + + public function testDeleteWhere(): void + { + $this->repository->create(['name' => 'a', 'qty' => 1]); + $this->repository->create(['name' => 'a', 'qty' => 2]); + $this->repository->create(['name' => 'b', 'qty' => 3]); + + $this->repository->deleteWhere('name = ?', ['a']); + + $this->assertCount(1, $this->repository->getList('*')); + } + + public function testTransactionRollsBackOnException(): void + { + try { + $this->repository->transaction(function () { + $this->repository->create(['name' => 'should_rollback', 'qty' => 1]); + throw new \RuntimeException('boom'); + }); + $this->fail('Ожидалось RuntimeException'); + } catch (\RuntimeException) { + } + + $this->assertCount(0, $this->repository->getList('*', ['name' => 'should_rollback'])); + } + + public function testGetListFetchAssoc(): void + { + $this->repository->create(['name' => 'foo', 'qty' => 1]); + $rows = $this->repository->getList('*', [], '', 'ASC', PDO::FETCH_ASSOC); + + $this->assertIsArray($rows[0]); + $this->assertSame('foo', $rows[0]['name']); + } +} diff --git a/tests/Unit/StatementTest.php b/tests/Unit/StatementTest.php new file mode 100644 index 0000000..5fd6717 --- /dev/null +++ b/tests/Unit/StatementTest.php @@ -0,0 +1,52 @@ +driver = new PdoDriver($config); + + try { + $this->driver->pdo(); + } catch (MyException $e) { + $this->markTestSkipped('Нет живого подключения к MariaDB (заполните App/config/config.local.php): ' . $e->getMessage()); + } + } + + protected function tearDown(): void + { + Profiler::reset(); + } + + public function testShowQuerySubstitutesPositionalParams(): void + { + $statement = $this->driver->query('SELECT ? AS a, ? AS b', ['foo', 42]); + + $this->assertSame("SELECT 'foo' AS a, 42 AS b", $statement->showQuery()); + } + + public function testShowQuerySubstitutesNamedParams(): void + { + $statement = $this->driver->query('SELECT :name AS name', ['name' => 'bicycle']); + + $this->assertSame("SELECT 'bicycle' AS name", $statement->showQuery()); + } + + public function testSqIsAliasForShowQuery(): void + { + $statement = $this->driver->query('SELECT ? AS a', ['foo']); + + $this->assertSame($statement->showQuery(), $statement->sq()); + } +} diff --git a/tests/Unit/ViewTest.php b/tests/Unit/ViewTest.php index 4eeede0..8eb5f93 100644 --- a/tests/Unit/ViewTest.php +++ b/tests/Unit/ViewTest.php @@ -20,6 +20,14 @@ class ViewTest extends TestCase if (file_exists($this->tmpFile)) { unlink($this->tmpFile); } + + // Сбрасываем статические реестры CSS/JS/manifest между тестами + $ref = new \ReflectionClass(View::class); + foreach (['_styles' => [], '_scripts' => [], '_manifest' => null] as $prop => $default) { + $property = $ref->getProperty($prop); + $property->setAccessible(true); + $property->setValue(null, $default); + } } public function testRenderStaticContent(): void @@ -117,4 +125,100 @@ class ViewTest extends TestCase $result = $view->setFilename('index', 'view/Index'); $this->assertSame($view, $result); } + + public function testSetStyleRegistersLinkTag(): void + { + (new View())->setStyle('app.css'); + ob_start(); + (new View())->getStyles(); + $html = ob_get_clean(); + + $this->assertStringContainsString('', $html); + } + + public function testSetStyleMissingFileEchoesAlert(): void + { + ob_start(); + (new View())->setStyle('nonexistent_xyz.css'); + $html = ob_get_clean(); + + $this->assertStringContainsString('alert-danger', $html); + $this->assertStringContainsString('nonexistent_xyz.css', $html); + + ob_start(); + (new View())->getStyles(); + $this->assertSame('', ob_get_clean()); + } + + public function testSetScriptRegistersScriptTag(): void + { + (new View())->setScript('app.js'); + ob_start(); + (new View())->getScripts(); + $html = ob_get_clean(); + + $this->assertStringContainsString( + '', $html + ); + } + + public function testSetScriptCustomType(): void + { + (new View())->setScript('app.js', 'module'); + ob_start(); + (new View())->getScripts(); + $html = ob_get_clean(); + + $this->assertStringContainsString('type="module"', $html); + } + + public function testSetScriptMissingFileEchoesAlert(): void + { + ob_start(); + (new View())->setScript('nonexistent_xyz.js'); + $html = ob_get_clean(); + + $this->assertStringContainsString('alert-danger', $html); + + ob_start(); + (new View())->getScripts(); + $this->assertSame('', ob_get_clean()); + } + + public function testSetManifestRegistersLinkTag(): void + { + (new View())->setManifest('manifest.json'); + ob_start(); + (new View())->getStyles(); + $html = ob_get_clean(); + + $this->assertStringContainsString('', $html); + } + + public function testSetManifestMissingFileEchoesAlert(): void + { + ob_start(); + (new View())->setManifest('nonexistent_xyz.json'); + $html = ob_get_clean(); + + $this->assertStringContainsString('alert-danger', $html); + } + + public function testGetStylesAndScriptsAreSharedAcrossViewInstances(): void + { + // content и layout — разные объекты View, но реестр общий (см. Controller::render()) + (new View())->setStyle('app.css'); + (new View())->setScript('app.js'); + + ob_start(); + (new View())->getStyles(); + $styles = ob_get_clean(); + + ob_start(); + (new View())->getScripts(); + $scripts = ob_get_clean(); + + $this->assertStringContainsString('app.css', $styles); + $this->assertStringContainsString('app.js', $scripts); + } }