From 69ce89a69518aef6574b7e910fa5673eb83259c9 Mon Sep 17 00:00:00 2001 From: Egor Isaev Date: Tue, 11 Aug 2026 17:05:39 +0300 Subject: [PATCH] dev --- .../feedback_reference_code_handling.md | 5 +- .claude/memory/roadmap.md | 16 +- .../Общий_промт_по_конструктору_метрик.md | 168 +++++++++ .claude/memory/бюджет_текущий_план.md | 323 ++++++++++++++++-- ...по_конкретной_реализации_конструктора_метрик.md | 92 +++++ .gitignore | 3 +- App/Repositories/AccountRateRepository.php | 36 ++ App/Repositories/AccountRepository.php | 25 ++ App/Repositories/BudgetRepository.php | 25 ++ App/Repositories/CategoryRepository.php | 25 ++ App/Repositories/GoalRepository.php | 25 ++ App/Repositories/TransactionRepository.php | 25 ++ App/Repositories/UserRepository.php | 25 ++ App/config/config.php | 14 +- App/logs/.gitkeep | 0 App/media/css/.gitkeep | 0 App/media/js/.gitkeep | 0 App/view/Index/index.html | 2 + CLAUDE.md | 50 ++- .../{PdoDriver.php => PdoConnection.php} | 86 +++-- Services/DataBase/Classes/Statement.php | 10 +- Services/Database.php | 25 +- System/Classes/ProfilerToolbar.php | 2 +- System/Classes/Repository.php | 10 +- img.jpg | Bin 0 -> 34947 bytes schema.sql | 110 ++++++ tests/Unit/DatabaseTest.php | 18 +- tests/Unit/PdoConnectionTest.php | 164 +++++++++ tests/Unit/PdoDriverTest.php | 145 -------- tests/Unit/ProfilerTest.php | 2 +- tests/Unit/RepositoryTest.php | 18 +- tests/Unit/StatementTest.php | 16 +- 32 files changed, 1185 insertions(+), 280 deletions(-) create mode 100644 .claude/memory/Общий_промт_по_конструктору_метрик.md create mode 100644 .claude/memory/промт_по_конкретной_реализации_конструктора_метрик.md create mode 100644 App/Repositories/AccountRateRepository.php create mode 100644 App/Repositories/AccountRepository.php create mode 100644 App/Repositories/BudgetRepository.php create mode 100644 App/Repositories/CategoryRepository.php create mode 100644 App/Repositories/GoalRepository.php create mode 100644 App/Repositories/TransactionRepository.php create mode 100644 App/Repositories/UserRepository.php create mode 100644 App/logs/.gitkeep create mode 100644 App/media/css/.gitkeep create mode 100644 App/media/js/.gitkeep rename Services/DataBase/Classes/{PdoDriver.php => PdoConnection.php} (61%) create mode 100644 img.jpg create mode 100644 schema.sql create mode 100644 tests/Unit/PdoConnectionTest.php delete mode 100644 tests/Unit/PdoDriverTest.php diff --git a/.claude/memory/feedback_reference_code_handling.md b/.claude/memory/feedback_reference_code_handling.md index 747f058..0e93cd6 100644 --- a/.claude/memory/feedback_reference_code_handling.md +++ b/.claude/memory/feedback_reference_code_handling.md @@ -23,12 +23,13 @@ render()-методы Controller, CSS/JS-подключение во View). Эт 1. Когда пользователь присылает референс-код — сначала спросить себя (не обязательно вслух), реально ли это то, что нужно 1-в-1, или в нём могут быть баги/устаревшие паттерны конкретно под старый проект (например поддержка нескольких "модулей", которых в Bicycle нет). -2. Портировать с адаптацией под реалии Bicycle (уже собранные классы вроде `PdoDriver` с его SAVEPOINT- +2. Портировать с адаптацией под реалии Bicycle (уже собранные классы вроде `PdoConnection` с его SAVEPOINT- логикой могут делать что-то лучше/надёжнее, чем то, что в референсе — не дублировать хуже). 3. Если сомневаешься, действительно ли имя/сигнатура должны совпадать буквально — можно посмотреть исходник самого референс-проекта на диске (он часто лежит рядом, в других папках `/home/isaevea/http/*`), а не гадать по вставленным фрагментам — так нашлись оригиналы `Database.php`/`PdoDriver.php`/ - `Statement.php`/`ProfilerPDO.php`/`Repository.php` в `eoffice_v3/System/Classes/`. + `Statement.php`/`ProfilerPDO.php`/`Repository.php` в `eoffice_v3/System/Classes/` (имя файла + там осталось `PdoDriver.php` — это чужой проект, не переименовывался вслед за нашим `PdoConnection`). 4. Если пользователь явно пишет что-то вроде `$this->pdo` (даже с ошибкой в другом месте кода) — это сильный сигнал именно про нейминг, не опечатка; после второго повтора — не переспрашивать, а сразу переименовывать под него (см. также [[feedback-less-confirmation]]). diff --git a/.claude/memory/roadmap.md b/.claude/memory/roadmap.md index 14f7b87..527c9ec 100644 --- a/.claude/memory/roadmap.md +++ b/.claude/memory/roadmap.md @@ -10,7 +10,7 @@ metadata: за одной абстракцией). Подробности использования — CLAUDE.md → разделы DataBase/Elasticsearch/Mongo. - **Реляционная БД (MariaDB, полностью рабочая и протестированная, реальное подключение)** — - `Services\Database` (фабрика), `Services\DataBase\Classes\PdoDriver` (failover по хостам, транзакции + `Services\Database` (фабрика), `Services\DataBase\Classes\PdoConnection` (failover по хостам, транзакции с вложенными SAVEPOINT — единственный источник истины по вложенности, общий на всё подключение), `Services\DataBase\Classes\Statement` (`showQuery()`/`sq()`, `execute()` профилирует в `Profiler`), `Services\DataBase\Classes\Profiler` (тайминги, см. также `System\Classes\ProfilerToolbar`). @@ -20,7 +20,7 @@ metadata: 1. `getList()` по умолчанию `PDO::FETCH_CLASS` с проброшенным `$this->obj_class` (в оригинале — `PDO::FETCH_KEY_PAIR` по умолчанию, и `FETCH_CLASS` не получает `obj_class` вовсе — нестыковка PHPDoc/кода в оригинале, не повторяли). - 2. Транзакции (`beginTransaction/commit/rollBack`) — тонкие делегаты в `PdoDriver` (см. выше), а не свой + 2. Транзакции (`beginTransaction/commit/rollBack`) — тонкие делегаты в `PdoConnection` (см. выше), а не свой счётчик вложенности в каждом `Repository`, как в оригинале (там это ломается, если два репозитория на одном соединении оба вызывают `beginTransaction()` — оба думают, что «внешние»). Методы: `get()/getItemWhere()/getList()/create()/update()/delete()/deleteWhere()`, `primary_col`/`is_auto_increment` @@ -44,8 +44,8 @@ metadata: - **`System\Classes\ProfilerToolbar`** — debug-панель внизу страницы (аналог ProfilerToolbar для Kohana), время/память/SQL из `Profiler`, видна только `role === 'admin'`, подключена в `App/view/layout.html`. -**Профилирование — в `Statement::execute()`, не в `PdoDriver::query()` (важно, был реальный баг):** -первая версия вешала таймер только на `PdoDriver::query()` через отдельный класс `ProfilerPDO::wrap()` +**Профилирование — в `Statement::execute()`, не в `PdoConnection::query()` (важно, был реальный баг):** +первая версия вешала таймер только на `PdoConnection::query()` через отдельный класс `ProfilerPDO::wrap()` (теперь удалён) — но `Repository::get()/getList()/create()/update()/delete()/deleteWhere()` сами делают `$this->pdo->prepare($sql); $stmt->execute();` напрямую, в обход `query()` — то есть почти все реальные запросы через `Repository` не логировались вообще (пользователь поймал это по `ProfilerToolbar`, @@ -53,13 +53,13 @@ metadata: что через `Statement` проходит вообще любое выполнение запроса (и `query()`, и ручной `prepare()+execute()`), таймер и вызов `Profiler::log()` теперь в `Statement::execute()` — единственной точке, которую нельзя обойти. Общий урок: если добавляешь профилирование/логирование поверх PDO-обёртки — вешать его на -низкоуровневый общий метод (`Statement`/`PDOStatement`), а не на удобный высокоуровневый (`PdoDriver::query()`), +низкоуровневый общий метод (`Statement`/`PDOStatement`), а не на удобный высокоуровневый (`PdoConnection::query()`), если у обёртки есть другие пути выполнения запроса в обход этого высокоуровневого метода. -**Свойство `Repository`/`PdoDriver` называется `$pdo`** (не `$_driver`/`$_pdo` с подчёркиванием, хотя это +**Свойство `Repository`/`PdoConnection` называется `$pdo`** (не `$_driver`/`$_pdo` с подчёркиванием, хотя это нарушает общий стиль `_prefixed` protected-свойств проекта) — пользователь явно попросил именно так, дважды сам написал `$this->pdo` в своём коде раньше, чем я успел объяснить структуру. См. также -`PdoDriver::rollback()` — с маленькой буквы (не `rollBack()`, хотя нативный `\PDO::rollBack()` — с +`PdoConnection::rollback()` — с маленькой буквы (не `rollBack()`, хотя нативный `\PDO::rollBack()` — с большой); `Repository::rollBack()` (с большой буквы) — публичный метод, который делегирует в `$this->pdo->rollback()` (с маленькой) — это НЕ опечатка, два разных метода двух разных классов. @@ -74,7 +74,7 @@ metadata: (внешний хост, не контейнер `db` из docker-dev) — эта часть инфраструктуры уже готова пользователем, тесты по MariaDB реально гоняются, не skip. -**Тесты**: `PdoDriverTest`/`StatementTest`/`RepositoryTest` бьют по реальной MariaDB (не мокают) — гейт +**Тесты**: `PdoConnectionTest`/`StatementTest`/`RepositoryTest` бьют по реальной MariaDB (не мокают) — гейт через `markTestSkipped()` в `setUp()` при неудачном подключении остался в коде на случай, если у кого-то ещё не настроен `config.local.php`, но сейчас реально проходят (не skip). `ElasticsearchClientTest` — через `Client::ping()`. `MongoDriverTest` — через `extension_loaded('mongodb')`. Эти два оживут сами, diff --git a/.claude/memory/Общий_промт_по_конструктору_метрик.md b/.claude/memory/Общий_промт_по_конструктору_метрик.md new file mode 100644 index 0000000..cc81e7d --- /dev/null +++ b/.claude/memory/Общий_промт_по_конструктору_метрик.md @@ -0,0 +1,168 @@ +Спроектируй и реализуй универсальный конструктор пользовательских метрик для аналитического дашборда. + +Цель + +Пользователь без знания программирования должен иметь возможность собрать собственную метрику из доступных данных, настроить способ расчёта и выбрать её визуальное представление. + +Конструктор должен подходить как для простых показателей, так и для сложных вычисляемых метрик. + +Основной сценарий + +Раздели создание метрики на три логических этапа: + +1. Основная информация. +2. Расчёт. +3. Отображение. + +Пользователь должен свободно перемещаться между этапами, не теряя введённые данные. + +Основная информация + +Предусмотри: + +- название метрики; +- необязательное описание; +- категорию или смысловую группу; +- единицу измерения; +- понятное отличие новой метрики от редактирования существующей. + +Расчёт + +Поддержи несколько способов создания метрики: + +- выбор готового показателя; +- сумма выбранных значений; +- разница между показателями; +- отношение или процент; +- среднее, минимум и максимум; +- накопительный итог; +- собственная формула. + +Источники данных должны определяться динамически. Не используй жёстко заданные категории или показатели. + +Позволь выбирать: + +- набор учитываемых данных; +- временной период; +- режим расчёта; +- фильтры; +- группировку; +- правила обработки отсутствующих значений. + +Формулы + +Добавь редактор собственных формул с поддержкой: + +- арифметических операций; +- скобок; +- доступных показателей и категорий; +- ссылок на другие пользовательские метрики; +- функций агрегации; +- сравнений с предыдущими периодами; +- автодополнения; +- описания доступных переменных; +- проверки формулы до сохранения. + +Конструктор должен обнаруживать: + +- неизвестные переменные; +- синтаксические ошибки; +- деление на ноль; +- циклические зависимости между метриками; +- несовместимые единицы измерения; +- отсутствующие источники данных. + +Ошибки показывай рядом с проблемным полем и объясняй понятным языком. + +Отображение + +Позволь настроить: + +- формат числа; +- количество знаков после запятой; +- денежную единицу или процент; +- направление оценки результата; +- целевое значение; +- предупредительные и критические пороги; +- цветовые состояния; +- выбранные графики; +- отображение на дашборде. + +Поддержи основные варианты визуализации: + +- числовая карточка; +- прогресс; +- сравнение; +- динамика; +- столбчатый график; +- линейный график; +- компактный индикатор состояния. + +Показывай живое превью метрики. Оно должно обновляться при изменении настроек, но не сохранять черновик как готовую метрику. + +Пользовательский опыт + +Интерфейс должен постепенно раскрывать сложность: + +- сначала показывать только обязательные параметры; +- дополнительные настройки размещать в раскрывающихся блоках; +- скрывать поля, которые не относятся к выбранному типу расчёта; +- сохранять незавершённый черновик; +- предупреждать при закрытии формы с несохранёнными изменениями; +- не перегружать экран вспомогательным текстом. + +Предусмотри понятные состояния: + +- пустые данные; +- загрузка; +- ошибка; +- некорректная формула; +- успешное сохранение; +- редактирование; +- дублирование; +- удаление и восстановление. + +Управление метриками + +Пользователь должен иметь возможность: + +- создавать метрики; +- редактировать; +- дублировать; +- временно скрывать; +- закреплять на дашборде; +- менять порядок; +- переносить в хранилище; +- удалять с возможностью восстановления; +- окончательно удалять; +- импортировать и экспортировать конфигурации. + +Совместимость + +Модель метрики должна быть версионируемой. При развитии конструктора старые сохранённые метрики должны продолжать работать или автоматически мигрировать в новую схему. + +Изменение или удаление источника данных не должно незаметно ломать метрику. Показывай её состояние и предлагай выбрать замену. + +Адаптивность и доступность + +Конструктор должен одинаково хорошо работать на компьютерах и мобильных устройствах: + +- без горизонтального скролла; +- с удобными зонами нажатия; +- с поддержкой экранной клавиатуры; +- с полной клавиатурной навигацией; +- с корректными focus-состояниями; +- с доступными названиями элементов; +- с достаточным контрастом. + +Критерии готовности + +- Простую метрику можно создать за несколько понятных действий. +- Сложную формулу можно собрать и проверить до сохранения. +- Все параметры влияют на живое превью. +- Некорректную конфигурацию невозможно сохранить. +- Ошибки объясняют причину и способ исправления. +- Старые метрики остаются совместимыми. +- Интерфейс корректно работает на мобильных и настольных экранах. +- Нет жёсткой зависимости от конкретных категорий, показателей или структуры данных. +- Основные пользовательские сценарии покрыты автоматическими тестами. \ No newline at end of file diff --git a/.claude/memory/бюджет_текущий_план.md b/.claude/memory/бюджет_текущий_план.md index b3a58e1..d91a9c9 100644 --- a/.claude/memory/бюджет_текущий_план.md +++ b/.claude/memory/бюджет_текущий_план.md @@ -20,9 +20,96 @@ metadata: `id`, `login`, `password`, `name`, `email`, `date_add`, `last_login`. Пока достаточно. +## Мультипользовательский режим — согласованный дизайн (гипотетика, НЕ в текущем приоритете, не кодировать) + +Обсуждали отдельно от основного скоупа, пользователь попросил зафиксировать. Два независимых механизма, +оба дёшевы и не требуют объединять/сверять чужие бюджеты между собой: + +1. **Изолированные бюджеты (например «я» и «сын»)** — ничего доделывать не нужно, уже работает: каждый + `users.id` — это и есть владелец полностью независимого бюджета, `accounts`/`categories`/ + `transactions`/`budgets`/`goals` уже фильтруются по своему `user_id` (см. "Продукт" выше — заложено + с самого начала на этот случай). Один бюджет никак не видит другой, ничего сверять/делить не нужно + (в отличие от гипотезы про «общие расходы между раздельными бюджетами», которую обсуждали и + отбросили — пользователь явно сказал не думать про дележ ЖКХ/общих счетов). + +2. **Viewer/editor доступ к ЧУЖОМУ бюджету (например жена смотрит бюджет мужа)** — новая маленькая + таблица-разрешение, не полноценная система приглашений: + ```sql + CREATE TABLE budget_access ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + owner_user_id INT UNSIGNED NOT NULL, -- чей бюджет (users.id) + viewer_login VARCHAR(64) NOT NULL, -- логин из Services\Auth — кого пускаем + role ENUM('viewer','editor') NOT NULL DEFAULT 'viewer', + FOREIGN KEY (owner_user_id) REFERENCES users (id) + ); + ``` + Логика: человек логинится через `Services\Auth` (уже поддерживает несколько логинов). Приложение + смотрит — есть ли у него свой `users.id` (владеет своим бюджетом), и есть ли записи в `budget_access` + по его `login` (доступ к чужим). Оба факта независимы и совместимы одновременно: можно и владеть + своим бюджетом, и иметь viewer/editor-доступ к чужому сразу — это НЕ взаимоисключающие случаи. + +**Следствие для UI**: если у залогиненного человека доступ больше чем к одному бюджету (свой + чужой +через `budget_access`), нужен переключатель «чей бюджет сейчас смотрим». + +**Точечная видимость внутри доступа (например скрыть «Подарки», чтобы не спалить сюрприз жене)** — +блок-лист поверх `budget_access`, не allow-лист (по умолчанию viewer видит всё разрешённое, точечно +скрываем конкретные счета/категории, а не наоборот — перечислять вручную всё видимое было бы утомительно): +```sql +CREATE TABLE budget_access_restrictions ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + budget_access_id INT UNSIGNED NOT NULL, -- к какому разрешению (budget_access) относится + entity_type ENUM('account', 'category') NOT NULL, + entity_id INT UNSIGNED NOT NULL, -- id скрываемого счёта или категории + FOREIGN KEY (budget_access_id) REFERENCES budget_access (id) +); +``` +Скрытие категории должно прятать и связанные с ней транзакции/план-факт в отчётах viewer'а (не +спроектировано, как именно — фильтровать на уровне запроса или на уровне рендера). + +**Решено, с примером.** У viewer'а те же две цифры, что у владельца («Доступно сейчас» и «Всего по +всем счетам»), но пересчитанные только по счетам, которые ему разрешены (не заблокированы через +`budget_access_restrictions`). Два независимых уровня фильтрации, не путать: +1. **`include_in_total`** — решает владелец, на каждом своём счёте: считать ли его в СВОЁ «Доступно + сейчас» (кошелёк/карта — да, копилка — обычно нет). +2. **Доступ viewer'а** (`budget_access_restrictions`) — более грубый фильтр: какие счета viewer вообще + видит. Скрытый от viewer'а счёт не участвует ни в одной из его двух сумм, независимо от того, какая + у этого счёта галочка `include_in_total` у владельца. + +Пример: Лена смотрит бюджет Егора, но копилка «Остаток» от неё скрыта. У Лены «Доступно сейчас» = +кошелёк+карта (то же, что и у Егора, минус «Остаток», которого она вообще не видит), «Всего по всем +счетам» — тоже без «Остатка». Скрытый счёт просто не участвует в подсчёте — не до конца проговорено, +нужно ли отдельно прятать сам факт его существования (не только сумму), и блок-лист это или allow-лист +по ощущению пользователя — переспросить при реализации. + +**Побочный эффект для схемы**: при таком дизайне `login`/`password` в таблице `users` (бюджетной) +становятся не нужны вообще — реальная авторизация целиком на `Services\Auth`, а `users` вырождается +в «чей это бюджет» (по сути `id` + `name`). Не менять `users` прямо сейчас — это следствие, которое +всплывёт, только если реально начнём кодировать мультипользовательский режим. + +**Побочный эффект для схемы**: при таком дизайне `login`/`password` в таблице `users` (бюджетной) +становятся не нужны вообще — реальная авторизация целиком на `Services\Auth`, а `users` вырождается +в «чей это бюджет» (по сути `id` + `name`). Не менять `users` прямо сейчас — это следствие, которое +всплывёт, только если реально начнём кодировать мультипользовательский режим. + ## Accounts (счета) -Три типа `type_acc`: +**Один пользователь приложения может вести несколько реальных счетов, включая счета, номинально +оформленные на других членов семьи** (пример: «мой счёт», «счёт жены») — это не мультипользовательская +функция и не требует отдельного `user_id` на счёт: все они просто разные строки `accounts` с разными +`title`, привязанные к одному `user_id` (см. "Продукт" выше). Различать их — по `title`, не по схеме. + +**Будущие типы счёта под инвестиции (не сейчас, см. "Приоритет фич" → Инвестиции)** — уточнено, это +два разных счёта, не один: +- **Брокерский** — акции, облигации, фонды, фьючерсы. +- **ЦФА** (цифровые финансовые активы) — долговые, ноты, картины (NFT-подобное), крипто. + +Оба не подходят ни под `cash` (не наличные), ни под `bank` (не просто расчётный счёт), ни под `savings` +(не вклад под процент, а портфель с рыночной переоценкой, не фиксированной ставкой). **Как именно с ними +работать (отдельные позиции/тикеры внутри счёта, переоценка, дивиденды/купоны, transfer при +покупке/продаже) — пользователь явно попросил отложить обсуждение, не проектировать сейчас вообще**, +даже на уровне схемы `accounts`/`type_acc`. Сейчас — только основной бюджет (cash/bank/savings). + +Три типа `type_acc` (актуальные, реализовано): - **`cash`** — кошелёк (наличные). - **`bank`** — счёт в банке; карта просто привязана к нему, сама по себе денег не хранит (это сознательное упрощение — раньше в модели были отдельные "дебетовая карта"/"кредитная карта"/ @@ -31,50 +118,234 @@ metadata: привязанная к `account_id`). Опционально может иметь цель (см. `goals` ниже) — если записи в `goals` нет, это просто копилка "без цели", копишь под процент без конкретной суммы/даты. -Поля: `id`, `user_id`, `type_acc`, `title`, `summa` (баланс), `is_delete` (архивация вместо удаления), -`order`. +Поля: `id`, `user_id`, `type_acc`, `title`, `summa` (баланс, вводится как есть при создании счёта — +любая сумма и своя валюта, не обязательно с нуля), `currency` (при создании счёта, вместе с суммой), +`description` (необязательное свободное поле), `is_delete` (архивация вместо удаления), `order`, +**`include_in_total`** (bool) — счёт учитывается в общей сумме на дашборде или нет; по умолчанию `true` +для `cash`/`bank`, `false` для `savings` — но это **чисто ручной переключатель пользователя, не +системное правило по типу/валюте счёта**. Подтверждено явно: то, что валютный счёт «Евро» был +исключён из «Доступно сейчас» на макете — не потому что валютные счета по умолчанию исключаются, а +потому что пользователь лично выбрал не включать именно этот счёт. Дефолт (true для cash/bank, false +для savings) — просто стартовое значение при создании счёта, дальше пользователь сам решает по каждому. + +**Счёт = title + summa + currency (+ description)** — минимальный набор полей, ничего структурного +сверху (например «принадлежит члену семьи X») не нужно — если понадобится пометить «это счёт жены», +это просто текст в `title`/`description`, не отдельное поле. + +**Уход в минус — невозможен для `cash`/`bank`/`savings`.** Овердрафт не подключается и не планируется; +наличным взяться неоткуда, если их нет. Если пользователь тратит больше, чем есть на счету — это +концептуально уже кредит (транзакция «с кредита»), а не отрицательный баланс обычного счёта. Кредиты +по-прежнему вне скоупа (см. "Приоритет фич"), но когда до них дойдём — они не про минус на обычном +счёте, а про отдельный тип счёта/операции. + +**Копилка/цель — намеренно тот же счёт, не отдельная сущность.** Пользователь явно подтвердил принцип +«не плодить сущности»: `savings` — это `accounts` с типом + история ставок (`account_rates`) + опционально +`goals` (цель) сверху, а не отдельная параллельная таблица «копилки». Это уже так и спроектировано — +просто явное подтверждение архитектурного выбора. + +**Курс валюты — автоматически, из внешнего источника (ЦБ РФ)**, не вручную (в отличие от процентной +ставки копилки — там осознанно вручную, см. `Account_rates` ниже; для курса валют внешний источник +есть и он надёжный, незачем дублировать руками). Не спроектировано пока: какой конкретно endpoint ЦБ РФ +(daily XML/JSON), куда класть клиент — скорее всего новый `Services\CurrencyRate` (по аналогии с +`Services\Elasticsearch`/`Services\Mongo` — точка входа + REST/HTTP-клиент), поверх уже существующего +`System\Classes\HTTP\Client\Curl`. Дашборд показывает курс для валют, которые реально встречаются среди +счетов пользователя (не весь список валют ЦБ РФ огулом). **Для дашборда курс — просто «сегодняшнее +значение» с кэшем на сутки, историю хранить для этого не нужно** (история нужна в другом месте, см. ниже). + +**Общая сумма на дашборде** — не сумма вообще всех счетов, а сумма счетов с `include_in_total = true`, +сконвертированная в рубли по текущему курсу для не-рублёвых. Рубль — базовая валюта для агрегации +(остальные конвертируются в неё для складывания в одну сумму). + +**Курс валюты на операции — фиксируется прямо в транзакции, не отдельной историей.** Возникло из +вопроса «отчёт по Отпуску, если траты в разных валютах» — решено, что в момент создания расходной +операции по валютному счёту курс ЦБ РФ на этот день записывается прямо в саму транзакцию и остаётся +неизменным навсегда (так же, как в реальных банковских FX-операциях — курс фиксируется в моменте, а не +ищется задним числом). Отдельная таблица `currency_rates` с историей по датам **не нужна** — это было +более сложное первое решение, отменено в пользу этого: +```sql +ALTER TABLE transactions ADD COLUMN currency CHAR(3) NULL; -- валюта операции (обычно = валюте счёта списания) +ALTER TABLE transactions ADD COLUMN rate_to_rub DECIMAL(10, 4) NULL; -- курс ЦБ РФ на момент операции; NULL для рублёвых +``` +Отчёт за период (например «Отпуск» за произвольные даты, не обязательно календарный месяц) суммирует +`summa * COALESCE(rate_to_rub, 1)` по нужным транзакциям — без join'ов и без логики «на эту дату курса +нет, берём ближайший». Период отчёта по категории — отдельная фича поверх `transactions`, не привязана +к месячной сетке `budgets` (та остаётся по месяцам, это для другого — план/факт, не разовые отчёты). ## Categories (статьи) — с подкатегориями `id`, `user_id`, `parent_id` (null — категория верхнего уровня; иначе — id родителя), `title`, `type` -(`income`/`expense`), `order`. +(`income`/`expense`), `order`, **`is_delete`** (архивация вместо удаления — тот же приём, что уже +есть у `accounts`; подтверждено: если по статье есть история транзакций, просто скрывать её из выбора +при вводе новой операции, не удалять физически). Пример: «Продукты» — родительская категория, «Алкоголь» — подкатегория внутри неё. Транзакция привязывается к конкретной (под)категории. В отчёте можно смотреть по родителю целиком (сумма всех подкатегорий) или провалиться в конкретную подкатегорию отдельно. +**Защита от дублирования статей — подтверждено, нужно.** Два сценария: +1. Заархивировали статью, забыли, завели новую с тем же названием — история разъезжается на две части. +2. Опечатка/невнимательность — завели одну и ту же статью дважды по рассеянности. + +Решение для обоих — на уровне приложения, не БД-constraint: при создании/переименовании статьи +проверять, нет ли уже **активной** (`is_delete = 0`) статьи с таким же `title` у **этого же +`user_id`** (в рамках типа доход/расход и той же родительской группы) — если есть, не давать создать +копию. Если находится **архивная** статья с тем же именем — предлагать восстановить её +(`is_delete = 0`) вместо создания новой. + +**Важно: проверка скоуплена по `user_id`, не глобально.** Если у двух разных (изолированных) +пользователей — например Егора и сына — у каждого своя статья «Питание», это два независимых ряда в +`categories` с одинаковым текстом в `title` и это нормально, конфликта нет — проверка сработала бы +только если ОДИН И ТОТ ЖЕ пользователь попытался завести такую статью у себя дважды. Изоляция +пользователей (см. раздел про мультипользовательский режим) и так гарантирует, что один вообще не +видит статьи другого — здесь ничего дополнительно обеспечивать не нужно. + +**Группировка — целиком в руках пользователя, через `parent_id`, никак не трогает историю.** +Пользователь явно подтвердил это как требование: перекинул статью в другую группу (сменил `parent_id`) +— старые `transactions` не меняются (они ссылаются на `categorie_id` листа, не на группу), просто +отчёты/дашборд на лету пересчитываются по текущей группировке при следующем открытии. Схему менять не +пришлось — `parent_id` уже это поддерживает. + +**Реальный список статей пользователя** (из его старой таблицы, не придумано мной) — +Доход: Зарплата Егор, Зарплата Лена, Квартира, Другие доходы. +Расход: 10%, К, Питание, Хозтовары, ЖКХ, ЖКХ Люберцы, Авто+Бензин, Проезд, Связь+Инет, Школа, Садик, +Дети, Медицина, Одежда, Личные расходы, Развлечение, Подарки, Парик/Красота, Отпуск, Хобби, +Образование, Спорт, Дом, Непредв. расходы, Непонятные расходы. + +Расшифровка непрозрачных названий: +- **«К»** — Кирилл (сын). Инвестиция на его счёт, вносится один раз в начале года всей суммой + (сложный процент выгоднее, чем вносить помесячно), но в плане (`budgets.plan_summ`) размазывается + по 3 000 ₽/мес. Следствие: факт в январе резко подскочит (реальная транзакция ~36 000 ₽), с февраля + по декабрь факт будет 0 — это ожидаемо, не баг. +- **«10%»** — отчисление 10% от зарплаты в копилку «Магнит», позже инвестируется. Та же природа, что + и «К» — плановая регулярная строка поверх нерегулярных реальных движений денег. +- **«ЖКХ» / «ЖКХ Люберцы»** — коммуналка по двум разным квартирам (не первая — родитель, вторая — + подкатегория; это две равноправные отдельные статьи, обе можно сгруппировать вместе, если нужно). + +Группировка (пример, обсуждали при показе макета категорий) — пользователь дал 5 групп-примеров: +«Основные» (ЖКХ, Питание, Связь), «Подписки» (Клод, ЧатGPT — новые примеры, не было в старом списке), +«Дети» (Биба, Боба — тоже новые имена, не факт что совпадают с «Дети»/«Школа»/«Садик»/«К»), «Транспорт» +(Бензин, Штрафы, Авто, Платные дороги), «Медицина» (Аптека, Лечение). Остальные статьи из старого +списка пользователь осознанно оставил без группы — сам разберёт в приложении, не наша забота гадать. + ## Transactions `id`, `user_id`, `categorie_id`, `account_f_id` (откуда), `account_in_id` (куда), `date`, `summa`, `type` (`income`/`expense`/`transfer`). Для дохода — только `account_in_id`, для расхода — только `account_f_id`, для перевода — оба. -**Обычный transfer между обычными счетами — без категории** (пример: переложил 3000 ₽ с карты в -кошелёк — деньги не потрачены и не заработаны, просто переехали, категория тут не имеет смысла). +**Все transfer — без категории, без исключений**, в том числе пополнение savings-счёта/копилки/цели: +переложил деньги в копилку «Отпуск» — это просто перемещение между своими счетами, не потрачено и не +заработано, `categorie_id = null`. **Решено** (закрывает предыдущий открытый вопрос про задвоение): +реальный расход считается только тогда, когда деньги действительно потрачены — гостиница, билеты и +т.п. оформляются обычными `expense`-транзакциями с `account_f_id` = счёт, с которого реально платили +(в т.ч. со счёта-копилки, если платили прямо с него), и категорией «Отпуск» (точнее — под-категорией +типа «Билеты»/«Гостиница»). Задвоения нет, потому что откладывание в копилку категорией не помечается +вовсе — план/факт по категории «Отпуск» видит только реальные траты. -**⚠️ ОТКРЫТЫЙ ВОПРОС — transfer на savings-счёт (пополнение копилки/цели) МОЖЕТ иметь категорию**, -и пользователь хочет, чтобы такой transfer засчитывался как "потрачено" по этой категории в -план/факт бюджета — то есть отложил 10 000 ₽ в копилку «Отпуск» в январе → сразу считается -"потрачено 10 000 на Отпуск" в январском бюджете, ещё до самой поездки. +**Баланс счёта (`accounts.summa`) — решено, «как в банках».** Не пересчитывать всю историю на каждый +показ, но и не хранить как самостоятельное число без контроля. Гибрид: `summa` — хранимый кэш, который +обновляется **атомарно, в той же БД-транзакции**, что и сама операция создания/правки/удаления +`transactions` (`PdoConnection::transaction()` уже есть для этого) — источник истины всё равно лента +операций, баланс — материализованный кэш поверх неё для скорости. Дополнительно нужен служебный метод +«пересчитать баланс с нуля из истории» — не для обычной работы, а как инструмент сверки/восстановления +при подозрении на расхождение (аналог банковской реконсиляции). -Нерешённая проблема с этим: когда деньги реально тратятся (сама поездка — гостиница, билеты), если те -траты ТОЖЕ пометить категорией «Отпуск» — сумма задвоится (одни и те же деньги посчитаны и при -откладывании, и при реальной трате). Пользователь сказал «я подумаю» — **решение пока не принято**, -не проектировать/не кодировать эту часть, пока не будет ответа. Варианты, которые обсуждались (но не -выбраны): (а) реальные траты в момент поездки НЕ помечать той же категорией, раз уже засчитано при -откладывании; (б) какой-то другой механизм анти-задвоения. +**Разбивка одной операции на несколько статей — подтверждено, нужно.** Один чек/покупка → несколько +пар (статья, сумма) в рамках одной операции (пример: поход в магазин — 500 ₽ Питание + 300 ₽ +Хозтовары). Это меняет схему `transactions`: `categorie_id`/`summa` на самой строке `transactions` +достаточно только для НЕразбитых операций; для разбитых нужна отдельная дочерняя таблица (условно +`transaction_items`: `id`, `transaction_id`, `categorie_id`, `summa`) — шапка `transactions` держит +дату/счета/общую сумму/тип, детали разбивки — в дочерних строках. Не спроектировано окончательно: +нужна ли разбивка для `income`/`transfer` тоже, или только для `expense` (вероятно только `expense` — +transfer и так без категории, доход обычно один источник). **Два способа ввода:** +1. **Голосовой** — пользователь надиктовывает через AI API (например «Питание 500 рублей, Хозтовары + 300 рублей»), система распознаёт речь и разбирает текст на пары статья/сумма автоматически. +2. **Ручной** — то же самое, но вводится руками, пара за парой. +Не спроектировано: какой конкретно AI API для голоса (Claude API уже упоминался в старом +`бюджет_проект_план.md` как ИИ-помощник — возможно, тот же), как обрабатывать ошибки распознавания. + +**Лог правок/удалений — отдельная таблица в БД** (не общий файловый `System\Classes\Log` — тот +подходит для аудита запросов вообще, но не для точечных «покажи все правки операции №123»). Правка и +удаление разрешены всегда, без ограничений по давности — но каждое изменение логируется: кто, когда, +что именно поменялось. Не спроектировано: точная структура (`transaction_history`: id, transaction_id, +user_id, changed_at, action, old_values/new_values — набросок, не финал). ## Budgets (план/факт) -`id`, `categorie_id`, `plan_summ`, `year`, `month`, `type`. План — вручную на месяц по категории; факт -— не хранится, считается на лету как сумма `transactions` по этой категории за месяц (с учётом -открытого вопроса выше — возможно, включая помеченные transfer на копилки). +`id`, `categorie_id`, `plan_summ`, `year`, `month`, `type`. Факт — не хранится, считается на лету как +сумма `transactions` по этой категории за месяц (с учётом открытого вопроса выше — возможно, включая +помеченные transfer на копилки). + +**Рабочий процесс ввода плана — не помесячно по ходу года, а весь год сразу.** План составляется один +раз, в конце года, сразу на все 12 месяцев следующего года по каждой статье (пример: в конце 2026-го +вводится план по «Проезду» на весь 2027-й — 12 чисел за один заход). Влияет на форму ввода — нужна не +«план на текущий месяц», а «план на год» с 12 полями (по одному на месяц) на каждую статью. + +**Открыто: формульные статьи бюджета.** Пользователь пояснил про «10%»: план = ЗП × 0,1, факт = +факт ЗП × 0,1 — то есть не фиксированное число и не сумма собственных транзакций, а процент от +другой категории (дохода). Концепция обсуждалась, не спроектирована окончательно: скорее всего +`budgets` получит необязательные `formula_source_categorie_id` + `formula_pct`, и для таких строк +план/факт вычисляются на лету от категории-источника, а не читаются напрямую. Не решено: один +источник или сумма нескольких (у пользователя два дохода — Зарплата Егор/Зарплата Лена), что +происходит при смене процента задним числом. Не проектировать/не кодировать, пока не обсудим детали. + +## Регулярные/автоматические операции — РЕШЕНО НЕ ДЕЛАТЬ + +Спрашивал явно — нужна ли фича «повторяющаяся операция», которая сама заводит транзакцию по расписанию +(для подписок типа Клод/ЧатGPT, коммуналки и т.п.). **Ответ: всё вручную**, автозаведения не нужно. +Обоснование пользователя: у подписок сумма почти всегда одна и та же, но может повыситься (автоматика +завела бы неверную сумму молча), а у коммуналки сумма всегда разная (автоматике там вообще нечего +угадывать) — в обоих случаях ручной ввод надёжнее, чем шаблон "повторить прошлый месяц". + +## Остаток / Резерв — РЕШЕНО НЕ ДЕЛАТЬ (обсуждали и отменили) + +Пользователь прислал реальный список категорий из своей старой таблицы, где внизу были строки +«Остаток», «Ост. с прошлого мес.», «Резерв» — сквозные метрики уровня всего бюджета (не по счёту, +не по категории). Формула, которую успели согласовать: Остаток(M) = Доход(M) − Расход(M); Резерв(M) = +Ост. с прошлого мес.(M) + Остаток(M); Ост. с прошлого мес.(M) = Резерв(M−1) — то есть running total +«Доход минус Расход» с начала учёта. + +**Но при уточнении пользователь сам решил, что это не нужно** — это был костыль конкретно таблицы, +у которой не было реальных остатков на счетах: приходилось вручную вычислять «сколько у меня реально +осталось» через накопительный доход-минус-расход. У нас есть настоящие `accounts.summa` (см. «Доступно +сейчас» на дашборде — сумма счетов с `include_in_total = true`) — это и есть тот самый ответ на вопрос +«сколько у меня есть», причём точнее (не зависит от того, залогирована ли вообще каждая транзакция). +А сравнение план/факт — уже отдельно покрыто таблицей `budgets` по категориям. Резерв как отдельная +сущность дублировал бы то, что уже есть в двух других местах — **не реализовывать**. ## Account_rates (история ставок) -`id`, `account_id`, `rate_percent`, `valid_from`, `valid_to` (null = текущая). Общая механика для -`savings`, с расчётом на будущее переиспользование для кредита (там % работает в другую сторону — -не начисление, а долг — но структура «ставка + период» та же). Кредит сейчас вне скоупа. +`id`, `account_id`, `rate_percent`, `valid_from`, `valid_to` (null = текущая). + +**История (не единственное поле на `accounts`) нужна ради конкретной фичи — узнать, сколько процентов +начислилось за произвольный выбранный период** (например «сколько капнуло за март»). Если ставка +внутри периода менялась, расчёт должен пройти по каждому отрезку `account_rates`, пересекающему +период, со своей ставкой — без истории это невозможно восстановить задним числом. Это и есть +причина оставить таблицу, а не одно поле `rate_percent` на `accounts` (более ранняя версия этого +раздела ошибочно связывала историю только с будущим кредитом — кредит по-прежнему вне скоупа, но +раз есть более близкий, реальный повод, обоснование хранения истории теперь этот, не кредитный). +Сам расчёт начисленных процентов за период (простой или сложный, как разбивать по датам) — ещё не +спроектирован, отдельная задача на будущее. + +**Ставка вводится вручную, без интеграции с банком** (это личный трекер, не агрегатор счетов) — +план, ЕЩЁ НЕ РЕАЛИЗОВАНО, возвращаемся к нему, когда дойдём до формы счёта: + +- **При создании копилки** (форма счёта, `type_acc = savings`) — доп. поле «Ставка, % годовых». + Создаёт первую строку `account_rates`: `valid_from` = дата создания счёта (или явно введённая + пользователем), `valid_to = null`. +- **При изменении ставки** — отдельное действие на странице счёта (не правка задним числом старой + строки — иначе теряется история для прошедших периодов). Пользователь вводит **и новый процент, и + дату, с которой он начинает действовать** (`valid_from` — не обязательно «сегодня», банк мог + прислать уведомление заранее/задним числом). Логика: найти текущую строку через + `AccountRateRepository::getCurrentRate($account_id)`, закрыть её (`valid_to` = день перед новым + `valid_from`), вставить новую (`valid_from` = введённая дата, `valid_to = null`, новый + `rate_percent`) — одной транзакцией (`$repo->transaction(...)`). +- Метод под это — `AccountRateRepository::setRate(int $account_id, float $rate_percent, string $valid_from)`. + Сейчас в `AccountRateRepository` есть только `getCurrentRate()` (чтение, уже реализовано и + протестировано на реальной БД) — `setRate()` ещё не написан. +- Прогноз «надо ≈X ₽/мес» у цели — открытый вопрос: линейно (без процента, проще) или честной + формулой аннуитета со сложным процентом (то, что буквально написано в `Goals` ниже — «по текущей + ставке») — не решено, обсудить перед реализацией расчёта. ## Goals (цель для копилки) @@ -86,6 +357,14 @@ metadata: категорией «Проценты по вкладам/целям» (одна на все копилки, не по одной на каждую) — заводится пользователю автоматически при первом использовании. +## Быстрый ввод операции — замена идее Telegram-бота + +В старом `бюджет_проект_план.md` (заметки ДО сброса, источник идей) был пункт «Telegram-бот для +быстрого ввода трат». **Переиграно**: вместо внешнего бота — страница быстрого добавления операции +внутри самого Bicycle (тот же стек, без бот-токена/вебхука/парсинга свободного текста от Telegram API). +**Не решено** — закреплять ли её как PWA-иконку на экране телефона (`View::setManifest()` уже +поддержан в проекте, см. CLAUDE.md) — пользователь пока не уверен, обсудить отдельно перед реализацией. + ## Приоритет фич 1. **Цели (план/факт) + анализ бюджета** — сейчас. diff --git a/.claude/memory/промт_по_конкретной_реализации_конструктора_метрик.md b/.claude/memory/промт_по_конкретной_реализации_конструктора_метрик.md new file mode 100644 index 0000000..d754ad7 --- /dev/null +++ b/.claude/memory/промт_по_конкретной_реализации_конструктора_метрик.md @@ -0,0 +1,92 @@ + +Задача: провести полный аудит и довести до законченного состояния конструктор пользовательских метрик для дашборда. Не переписывай работающий движок без необходимости — сначала изучи текущую реализацию в app/assets/js/metrics.js, app/assets/css/metrics.css и связанные тесты. + +Цель + +Пользователь должен без технических знаний собрать метрику, выбрать источник данных, настроить отображение и сразу понимать, что получится. Интерфейс должен быть компактным, визуально цельным и соответствовать брендовому glass-дизайну приложения. + +Функциональные требования + +1. Сохрани структуру конструктора из трёх разделов: + - «Основное»; + - «Расчёт»; + - «Отображение». + +2. В разделе «Основное»: + - название метрики; + - понятная идентификация создаваемой или редактируемой метрики; + - отсутствие лишнего поясняющего текста. + +3. Поддержи типы расчёта: + - сумма выбранных категорий; + - доходы минус расходы; + - доля расходов от дохода; + - собственная формула. + +4. Поддержи режимы данных: + - факт, если есть, иначе план; + - только факт; + - только план. + +5. Категории: + - получай исключительно из пользовательских данных; + - не возвращай хардкод категорий и позиций; + - пустой инстанс должен оставаться пустым; + - исторические категории должны оставаться доступными для расчётов; + - выбор категорий оформить компактным раскрывающимся блоком с количеством выбранных элементов. + +6. Собственные формулы: + - сохранить существующий движок формул; + - поддерживать базовые переменные, категории, ссылки на другие метрики и соседние периоды; + - поддерживать префиксы prev_, next_, prev2_ и next2_; + - автодополнение должно показывать полный доступный список без искусственного ограничения; + - неизвестные переменные, пустые выражения и синтаксические ошибки должны блокировать сохранение с понятной ошибкой; + - выбор результата: денежная сумма, коэффициент или процент; + - при собственной формуле отключать обычный выбор категорий, поскольку категории указываются токенами в выражении. + +7. Настройки отображения: + - сравнение; + - динамика; + - прогресс; + - направление оценки «больше — лучше» или «меньше — лучше»; + - целевое значение показывать только при выбранном графике прогресса; + - переключатель закрепления метрики на дашборде. + +8. Добавь живое превью карточки метрики внутри конструктора: + - название; + - рассчитанное или демонстрационное значение; + - единица измерения; + - выбранные графики; + - состояние цели; + - превью должно обновляться при изменении настроек и не сохранять данные само по себе. + +9. Сохранение: + - создание новой метрики; + - редактирование существующей без потери ID и owner; + - удаление с возможностью восстановления через существующие хранилище и корзину; + - метрики должны продолжать работать в дашборде и каталоге калькулятора; + - при изменении структуры данных предусмотреть schemaVersion и миграцию старых записей. + +Дизайн + +- Используй существующие брендовые переменные, стеклянные поверхности и SVG-иконки проекта. +- Не добавляй новые случайные цвета и стили. +- Модальное окно должно находиться поверх всего интерфейса и использовать общий popup/modal слой. +- На десктопе поля можно размещать в две колонки. +- На мобильном всё должно переходить в одну колонку без горизонтального скролла. +- Учти экранную клавиатуру, safe-area и небольшую высоту экрана. +- Не перегружай форму подсказками: оставляй только ошибки, важные ограничения и необходимые пояснения для формул. +- Состояния выбора, disabled, loading, validation и focus должны быть визуально различимы. + +Критерии готовности + +- Можно создать и отредактировать каждый тип метрики. +- Формулы корректно работают с планом, фактом, категориями, соседними периодами и другими метриками. +- Пустой инстанс не получает предустановленных категорий или метрик. +- Старые сохранённые метрики продолжают работать. +- Конструктор не выходит за границы экрана на desktop и mobile. +- Все существующие тесты проходят. +- Добавлены тесты на живое превью, валидацию, динамические категории, адаптив и обратную совместимость. +- Проведена проверка синтаксиса всех изменённых JS-файлов. + + diff --git a/.gitignore b/.gitignore index db85cee..c989633 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,8 @@ config.json App/config/config.local.php App/config/auth_users.php -/App/logs/ +/App/logs/* +!/App/logs/.gitkeep **/files/**/* !**/files/**/ diff --git a/App/Repositories/AccountRateRepository.php b/App/Repositories/AccountRateRepository.php new file mode 100644 index 0000000..e5bd244 --- /dev/null +++ b/App/Repositories/AccountRateRepository.php @@ -0,0 +1,36 @@ +getItemWhere("account_id = $account_id AND valid_to IS NULL"); + } +} diff --git a/App/Repositories/AccountRepository.php b/App/Repositories/AccountRepository.php new file mode 100644 index 0000000..85f85ae --- /dev/null +++ b/App/Repositories/AccountRepository.php @@ -0,0 +1,25 @@ + [ 'default' => [ - 'driver' => 'pdo', - 'connection' => [ - 'host' => '192.168.1.36', - 'port' => 3307, - 'user' => 'root', - 'password' => 'qwerty', - 'dbname' => 'budget' - ], + 'dialect' => 'mysql', // mysql|pgsql|sqlite — какой DSN собирает PdoConnection::buildDsn() + 'host' => '192.168.1.36', + 'port' => 3307, + 'user' => 'root', + 'password' => 'qwerty', + 'dbname' => 'budget' ], ], diff --git a/App/logs/.gitkeep b/App/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/App/media/css/.gitkeep b/App/media/css/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/App/media/js/.gitkeep b/App/media/js/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/App/view/Index/index.html b/App/view/Index/index.html index f0a2e7e..b68b375 100644 --- a/App/view/Index/index.html +++ b/App/view/Index/index.html @@ -8,6 +8,7 @@ */ use System\Classes\CSRF; +use System\Classes\ProfilerToolbar; ?> @@ -25,6 +26,7 @@ use System\Classes\CSRF;

Вы вошли как .

+
diff --git a/CLAUDE.md b/CLAUDE.md index d6433c7..48703de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ App/config/ — конфиги (config.php; config.local. App/view/ — шаблоны приложения (.html файлы с PHP-кодом) App/media/ — статические ресурсы (js, css, img) Services/Auth/ — авторизация: интерфейс AuthDriver + FileAuthDriver (см. раздел Auth ниже) -Services/Database.php, DataBase/ — реляционная БД (MariaDB/PDO): фабрика + Model/Classes/{PdoDriver,Statement,Profiler} (см. раздел DataBase ниже) +Services/Database.php, DataBase/ — реляционная БД (MariaDB/PDO): фабрика + Model/Classes/{PdoConnection,Statement,Profiler} (см. раздел DataBase ниже) Services/Elasticsearch.php, Elasticsearch/ — поиск: фабрика + Client (REST, см. раздел Elasticsearch ниже) Services/Mongo.php, Mongo/ — документная БД: фабрика + MongoDriver (см. раздел Mongo ниже) Services/Mail/, PDF/ — пустые каталоги-заготовки под будущие сервисы (PHPMailer / dompdf); кода пока нет @@ -109,12 +109,12 @@ 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` | Точка входа в реляционную БД (MariaDB): `instance(?string $name = null): PdoConnection`, кэш по имени подключения | +| `Services\DataBase\Classes\PdoConnection` | Обёртка над `PDO`: `query()`/`prepare()`/`exec()`, диалект `mysql`\|`pgsql`\|`sqlite` через конфиг `dialect` (по умолчанию `mysql`), failover по `hosts` (кроме sqlite — там нет хостов), транзакции с вложенными SAVEPOINT (`transaction()`) | | `Services\DataBase\Classes\Statement` | `extends PDOStatement` (через `PDO::ATTR_STATEMENT_CLASS`); `showQuery()`/`sq()` — SQL с подставленными параметрами, для дебага; `execute()` также профилирует в `Profiler` | | `Services\DataBase\Classes\Profiler` | Статический накопитель таймингов запросов: `log()`, `entries()`, `totalTime()`, `count()`, `reset()` | | `Services\DataBase\Model` | `#[AllowDynamicProperties]`, лёгкий пассивный носитель данных строки с настоящими (не спрятанными) свойствами — под `PDO::FETCH_CLASS`; `toArray()` | -| `System\Classes\Repository` | `@template T`, abstract CRUD поверх таблицы: `get()/getItemWhere()/getList()/create()/update()/delete()/deleteWhere()`, `beginTransaction()/commit()/rollBack()/transaction()` (делегируют в `PdoDriver`); конкретные — в `App\Repositories\*` | +| `System\Classes\Repository` | `@template T`, abstract CRUD поверх таблицы: `get()/getItemWhere()/getList()/create()/update()/delete()/deleteWhere()`, `beginTransaction()/commit()/rollBack()/transaction()` (делегируют в `PdoConnection`); конкретные — в `App\Repositories\*` | | `Services\Elasticsearch` | Точка входа в Elasticsearch: `instance(?string $name = null): Client`, кэш по имени подключения | | `Services\Elasticsearch\Client` | REST-обёртка на `HTTP\Client\Curl` (без composer-зависимости `elasticsearch/elasticsearch`): `index()/get()/search()/delete()/exists()/createIndex()/deleteIndex()/ping()` | | `Services\Mongo` | Точка входа в MongoDB: `instance(?string $name = null): MongoDriver`, кэш по имени подключения | @@ -311,34 +311,49 @@ return [ ### DataBase (Services\Database, System\Classes\Repository) Реляционная БД (MariaDB) через PDO. Конфиг — `Config::get('db', $name)`, ключ `$name` — это имя -*подключения* (не тип драйвера — драйвер сейчас всегда `'pdo'`), по умолчанию `'default'`: +*подключения*, по умолчанию `'default'`; значение — параметры подключения плоским массивом +(`host`/`hosts`/`port`/`user`/`password`/`dbname`/`dialect`/`charset`), без обёртки под тип +драйвера — PDO не «драйвер» в смысле выбора между несколькими реализациями (это сама обёртка над +клиентской библиотекой конкретной СУБД), других вариантов подключения к реляционной БД в проекте +нет, поэтому и нечего выбирать конфигом: ```php use Services\Database; -$driver = Database::instance(); // Config::get('db', 'default') -$stmt = $driver->query('SELECT * FROM users WHERE id = ?', [42]); +$connection = Database::instance(); // Config::get('db', 'default') +$stmt = $connection->query('SELECT * FROM users WHERE id = ?', [42]); $row = $stmt->fetch(); echo $stmt->sq(); // SQL с подставленными параметрами — для дебага (Statement::showQuery()) ``` -Подключение — ленивое (первое обращение к `pdo()`/`query()`), с failover: `connection.hosts` +Подключение — ленивое (первое обращение к `pdo()`/`query()`), с failover: `hosts` (массив) перебирается по порядку до первого успешного, иначе — `MyException` со списком ошибок -по каждому хосту. Транзакции на уровне `PdoDriver` — вложенные через SAVEPOINT +по каждому хосту. + +**Диалект** — `dialect` (`'mysql'` по умолчанию, либо `'pgsql'`/`'sqlite'`) переключает +только сборку DSN внутри `PdoConnection::buildDsn()` — реестра классов-драйверов по типу СУБД нет +(в отличие от `Auth`/`LogReader`), потому что весь остальной код (`Statement`, `Profiler`, +SAVEPOINT-транзакции) от диалекта не зависит, это осталось бы дублированием ради дублирования. +`sqlite` — особый случай: `dbname` там путь к файлу (или `':memory:'`), а не имя базы на хосте, +поэтому у него нет `host`/`hosts`/`user`/`password`/`charset` и failover-перебор для него не +запускается (`connect()` подключается напрямую). Расширения `pdo_mysql` и `pdo_sqlite` есть в +текущем Docker-образе, `pdo_pgsql` — нет (диалект `pgsql` в коде поддержан, но не проверен вживую). + +Транзакции на уровне `PdoConnection` — вложенные через SAVEPOINT (`beginTransaction()`/`commit()`/`rollback()` считают уровень вложенности сами), либо через обёртку: ```php -$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]); +$connection->transaction(function ($connection) { + $connection->query('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, 1]); + $connection->query('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, 2]); }); // исключение внутри колбэка → rollback (или ROLLBACK TO SAVEPOINT на вложенном уровне) + повторный throw ``` Тайминги запросов — `Services\DataBase\Classes\Profiler::entries()`/`totalTime()` (наполняется автоматически из `Statement::execute()` — единой точки для любого выполнения запроса, в т.ч. -ручного `$driver->prepare()->execute()` в обход `query()`, как делает большинство методов `Repository`). +ручного `$connection->prepare()->execute()` в обход `query()`, как делает большинство методов `Repository`). **Repository** — `System\Classes\Repository` (`@template T of object`, abstract CRUD), конкретные — в `App/Repositories/*`. Таблица и класс строки задаются через конструктор, не через переопределение @@ -366,7 +381,7 @@ $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-логика) +$repo->transaction(fn () => /* несколько операций одной транзакцией */ null); // begin/commit/rollBack — делегируют в PdoConnection (там же и SAVEPOINT-логика) ``` `public string $primary_col = 'id'` и `public bool $is_auto_increment = true` — переопределяются в @@ -381,7 +396,7 @@ $repo->transaction(fn () => /* несколько операций одной т нестыковка между PHPDoc и кодом в оригинале). Транзакции по той же причине не портированы один-в-один: там свой счётчик вложенности в каждом `Repository`, что ломается при двух репозиториях на одном соединении (оба думают, что они «внешние», оба зовут `PDO::beginTransaction()` — исключение); -в Bicycle транзакции делегируются в `PdoDriver`, у которого счётчик один на всё подключение. +в Bicycle транзакции делегируются в `PdoConnection`, у которого счётчик один на всё подключение. `Model` (`Services\DataBase\Model`, `#[AllowDynamicProperties]`) — лёгкий пассивный носитель данных, это класс по умолчанию для `T` в `Repository`. Свойства настоящие (не спрятаны за внутренним массивом) — @@ -627,7 +642,7 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap | `tests/Unit/FileAuthDriverTest.php` | `login()` верно/неверно/неизвестный логин, `loggedIn()`, `getUser()` без пароля, `logout()`, `checkPassword()`, отсутствие файла | | `tests/Unit/AuthTest.php` | `instance()` драйвер по умолчанию/явный, кэширование по драйверу, неизвестный драйвер → исключение | | `tests/Unit/DatabaseTest.php` | `instance()` кэш по имени подключения, неизвестное подключение/драйвер → исключение | -| `tests/Unit/PdoDriverTest.php` | failover (не требует БД), `query()`, транзакции/вложенные SAVEPOINT, `transaction()`, профилирование ручного `prepare()+execute()` — требует живую MariaDB, иначе `markTestSkipped()` | +| `tests/Unit/PdoConnectionTest.php` | failover (не требует БД), `query()`, транзакции/вложенные SAVEPOINT, `transaction()`, профилирование ручного `prepare()+execute()` — требует живую MariaDB, иначе `markTestSkipped()`; отдельно диалект `sqlite` (`:memory:`, не требует внешней БД) | | `tests/Unit/StatementTest.php` | `showQuery()`/`sq()` — подстановка позиционных/именованных параметров — требует живую MariaDB | | `tests/Unit/ProfilerTest.php` | `log()/entries()/totalTime()/count()/reset()` | | `tests/Unit/RepositoryTest.php` | `get()/getItemWhere()/getList()` (в т.ч. дефолтный `FETCH_CLASS` + `obj_class`)/`create()/update()/delete()/deleteWhere()`, `transaction()` с rollback — требует живую MariaDB | @@ -638,7 +653,8 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap | `tests/Unit/MongoDriverTest.php` | `instance()`, `collection()`, `database()` — требует `ext-mongodb`, иначе `markTestSkipped()` | | `tests/Unit/ProfilerToolbarTest.php` | `render()` пусто для гостя/не-admin, панель с временем/памятью/SQL для admin, вкладки Vars/Files/Route, маскировка чувствительных ключей, вкладка Custom только при `addData()`, `EXPLAIN` для реального SELECT / пропуск для не-SELECT | -**248 тестов, 386 assertion — все проходят (8 skipped: Elasticsearch/Mongo без живой инфраструктуры — MariaDB подключена и все её тесты реально проходят, см. разделы DataBase/Elasticsearch/Mongo выше).** +**248 тестов, 387 assertion — все проходят (8 skipped: Elasticsearch/Mongo без живой инфраструктуры — +MariaDB подключена и все её тесты реально проходят, см. разделы DataBase/Elasticsearch/Mongo выше).** ### Frontend dependencies (через Composer) diff --git a/Services/DataBase/Classes/PdoDriver.php b/Services/DataBase/Classes/PdoConnection.php similarity index 61% rename from Services/DataBase/Classes/PdoDriver.php rename to Services/DataBase/Classes/PdoConnection.php index 9119b79..19dfd19 100644 --- a/Services/DataBase/Classes/PdoDriver.php +++ b/Services/DataBase/Classes/PdoConnection.php @@ -2,7 +2,7 @@ /** * @package Bicycle * @author Egor Isaev - * @description PdoDriver.php + * @description PdoConnection.php * @copyright (c) 07/08/2026 */ @@ -17,8 +17,12 @@ use Throwable; /** * Обёртка над PDO: ленивое подключение (с перебором хостов при отказе — failover), * запросы через Statement (см. Statement::showQuery()) и вложенные транзакции через SAVEPOINT. + * Диалект (`mysql`/`pgsql`/`sqlite` — набор PDO-драйверов, реально доступных через + * pdo_mysql/pdo_pgsql/pdo_sqlite) задаётся конфигом, не подклассом — DSN и есть единственное + * отличие между ними на уровне этого класса, остального (Statement/Profiler/SAVEPOINT-транзакции) + * это не касается. */ -class PdoDriver +class PdoConnection { /** @var PDO|null Подключение; null до первого обращения (connect()/pdo()) */ protected ?PDO $_pdo = null; @@ -27,14 +31,18 @@ class PdoDriver protected int $_transaction_level = 0; /** - * @param array $config Параметры подключения: host|hosts, port, dbname, user, password, charset (по умолчанию utf8mb4) + * @param array $config Параметры подключения: dialect (mysql|pgsql|sqlite, по умолчанию mysql), + * host|hosts, port, dbname, user, password, charset (по умолчанию utf8mb4, + * только для mysql). Для sqlite dbname — путь к файлу (или ':memory:'), + * host/port/user/password/charset не используются. */ public function __construct(protected array $config) { } /** - * Подключается к первому доступному хосту из host|hosts (failover). + * Подключается к первому доступному хосту из host|hosts (failover); для sqlite (нет + * понятия хоста — файл или ':memory:') подключается напрямую, без перебора. * Не делает ничего, если подключение уже установлено. * * @return void @@ -45,26 +53,24 @@ class PdoDriver return; } - $hosts = $this->config['hosts'] ?? [$this->config['host'] ?? 'localhost']; - $port = $this->config['port'] ?? null; - $dbname = $this->config['dbname'] ?? ''; - $user = $this->config['user'] ?? ''; - $password = $this->config['password'] ?? ''; - $charset = $this->config['charset'] ?? 'utf8mb4'; + $dialect = $this->config['dialect'] ?? 'mysql'; + $options = [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]; + if ($dialect === 'sqlite') { + $this->_pdo = $this->connectPdo($this->buildDsn($dialect, null), $options); + + return; + } + + $hosts = $this->config['hosts'] ?? [$this->config['host'] ?? 'localhost']; $errors = []; foreach ($hosts as $host) { - $dsn = "mysql:host=$host;dbname=$dbname;charset=$charset" . ($port !== null ? ";port=$port" : ''); - 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; + $this->_pdo = $this->connectPdo($this->buildDsn($dialect, $host), $options); return; } catch (PDOException $e) { @@ -77,6 +83,46 @@ class PdoDriver ); } + /** + * Собирает DSN под конкретный PDO-диалект. + * + * @param string $dialect mysql|pgsql|sqlite + * @param string|null $host Хост; не используется для sqlite + * @return string + */ + protected function buildDsn(string $dialect, ?string $host): string + { + $dbname = $this->config['dbname'] ?? ''; + $port = $this->config['port'] ?? null; + $port_suffix = $port !== null ? ";port=$port" : ''; + + return match ($dialect) { + 'sqlite' => "sqlite:$dbname", + 'pgsql' => "pgsql:host=$host;dbname=$dbname$port_suffix", + default => "mysql:host=$host;dbname=$dbname;charset=" . ($this->config['charset'] ?? 'utf8mb4') . $port_suffix, + }; + } + + /** + * Открывает PDO-подключение по готовому DSN и настраивает Statement::class. + * sqlite не использует user/password — PDO принимает для него null. + * + * @param string $dsn + * @param array $options + * @return PDO + */ + protected function connectPdo(string $dsn, array $options): PDO + { + $is_sqlite = str_starts_with($dsn, 'sqlite:'); + $user = $is_sqlite ? null : ($this->config['user'] ?? ''); + $password = $is_sqlite ? null : ($this->config['password'] ?? ''); + + $pdo = new PDO($dsn, $user, $password, $options); + $pdo->setAttribute(PDO::ATTR_STATEMENT_CLASS, [Statement::class, [$this]]); + + return $pdo; + } + /** * @return PDO Подключается при первом обращении */ @@ -177,7 +223,7 @@ class PdoDriver /** * Выполняет $callback в транзакции: begin → callback → commit; * при исключении — rollback и повторный throw. Вложенные вызовы (в том числе - * из разных Repository на одном PdoDriver) используют SAVEPOINT прозрачно. + * из разных Repository на одном PdoConnection) используют SAVEPOINT прозрачно. * * @param callable $callback * @return mixed Результат $callback diff --git a/Services/DataBase/Classes/Statement.php b/Services/DataBase/Classes/Statement.php index b6f7475..e9924b8 100644 --- a/Services/DataBase/Classes/Statement.php +++ b/Services/DataBase/Classes/Statement.php @@ -13,10 +13,10 @@ use PDOStatement; /** * Расширение PDOStatement, подключается через PDO::ATTR_STATEMENT_CLASS - * (см. PdoDriver::connect()). Добавляет showQuery()/sq() — PDOStatement::debugDumpParams() + * (см. PdoConnection::connect()). Добавляет showQuery()/sq() — PDOStatement::debugDumpParams() * печатает в stdout, а не возвращает строку, поэтому значения bind-параметров * копятся вручную (bindValue()/execute() переопределены) и подставляются в SQL сами. - * execute() также профилирует время в Profiler — здесь, а не в PdoDriver::query(), + * execute() также профилирует время в Profiler — здесь, а не в PdoConnection::query(), * потому что через ATTR_STATEMENT_CLASS проходит вообще любое выполнение запроса, * в том числе ручной $pdo->prepare()->execute() в обход query() (как в Repository). */ @@ -34,9 +34,9 @@ class Statement extends PDOStatement /** * Конструктор вызывается только изнутри PDO (PDO::ATTR_STATEMENT_CLASS) — не создавать напрямую. * - * @param PdoDriver $driver Владелец, нужен для quote() в showQuery() + * @param PdoConnection $connection Владелец, нужен для quote() в showQuery() */ - protected function __construct(protected PdoDriver $driver) + protected function __construct(protected PdoConnection $connection) { } @@ -82,7 +82,7 @@ class Statement extends PDOStatement $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); + $quoted = is_int($value) || is_float($value) ? (string) $value : $this->connection->pdo()->quote((string) $value); if (is_int($key)) { $pos = strpos($sql, '?'); diff --git a/Services/Database.php b/Services/Database.php index ab146ef..16caf4a 100644 --- a/Services/Database.php +++ b/Services/Database.php @@ -8,32 +8,33 @@ namespace Services; -use Services\DataBase\Classes\PdoDriver; +use Services\DataBase\Classes\PdoConnection; use System\Classes\Config; use System\Classes\MyException; /** * Точка входа в реляционную БД. Имя аргумента — это имя подключения - * (ключ в Config::get('db', $name), по умолчанию 'default'), а не тип драйвера — - * сейчас есть только один драйвер ('pdo'), поэтому реестр по типу драйвера - * (как в Auth::$_drivers) не заводится, пока не появится второй. + * (ключ в Config::get('db', $name), по умолчанию 'default'). Подключение всегда через PDO — + * PDO не «драйвер» в смысле выбора между несколькими реализациями (это и есть сама обёртка + * над клиентской библиотекой конкретной СУБД, см. CLAUDE.md, раздел DataBase), поэтому в + * конфиге нет отдельного ключа под тип драйвера — только параметры подключения. * * Database::instance()->query('SELECT ...'); * Database::instance('reporting')->query(...); // отдельное подключение */ class Database { - /** @var array Кэш подключений по имени */ + /** @var array Кэш подключений по имени */ protected static array $_instances = []; /** * Возвращает подключение по имени (кэшируется). * * @param string|null $name Имя подключения из конфига; null — 'default' - * @return PdoDriver - * @throws MyException Если подключение не описано в конфиге или драйвер неизвестен + * @return PdoConnection + * @throws MyException Если подключение не описано в конфиге */ - public static function instance(?string $name = null): PdoDriver + public static function instance(?string $name = null): PdoConnection { $name ??= 'default'; @@ -44,13 +45,7 @@ class Database 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'] ?? []); + self::$_instances[$name] = new PdoConnection($config); } return self::$_instances[$name]; diff --git a/System/Classes/ProfilerToolbar.php b/System/Classes/ProfilerToolbar.php index c34c359..cf437cd 100644 --- a/System/Classes/ProfilerToolbar.php +++ b/System/Classes/ProfilerToolbar.php @@ -55,7 +55,7 @@ class ProfilerToolbar $time_ms = (microtime(true) - ($_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true))) * 1000; $memory = memory_get_peak_usage(true); // Захватываем ДО sqlTab(): EXPLAIN внутри неё сам логируется в Profiler (обычный query() - // через тот же PdoDriver) — иначе шапка "SQL: N (X ms)" включила бы наши же debug-запросы. + // через тот же PdoConnection) — иначе шапка "SQL: N (X ms)" включила бы наши же debug-запросы. $query_count = Profiler::count(); $query_time = Profiler::totalTime(); diff --git a/System/Classes/Repository.php b/System/Classes/Repository.php index 3e11a42..f1d4c62 100644 --- a/System/Classes/Repository.php +++ b/System/Classes/Repository.php @@ -11,7 +11,7 @@ namespace System\Classes; use JsonException; use PDO; use Services\Database; -use Services\DataBase\Classes\PdoDriver; +use Services\DataBase\Classes\PdoConnection; use Services\DataBase\Model; use Throwable; @@ -21,7 +21,7 @@ use Throwable; * Конкретный репозиторий — в App\Repositories\* (например App\Repositories\BudgetRepository * extends Repository), таблица/класс строки задаются через конструктор: * `parent::__construct('budget', Budget::class)` (по умолчанию — Services\DataBase\Model). - * Подключение — $this->pdo (PdoDriver, не сырой \PDO — но с тем же query()/prepare()/... + * Подключение — $this->pdo (PdoConnection, не сырой \PDO — но с тем же query()/prepare()/... * API, включая fetch-методы через Statement). */ abstract class Repository @@ -32,8 +32,8 @@ abstract class Repository /** @var bool Первичный ключ — автоинкремент (влияет на processData()/create()) */ public bool $is_auto_increment = true; - /** @var PdoDriver Подключение */ - protected PdoDriver $pdo; + /** @var PdoConnection Подключение */ + protected PdoConnection $pdo; /** @var string Таблица, с которой работает репозиторий */ protected string $table; @@ -54,7 +54,7 @@ abstract class Repository } /** - * Начинает транзакцию; на вложенном уровне — SAVEPOINT (см. PdoDriver — он же считает + * Начинает транзакцию; на вложенном уровне — SAVEPOINT (см. PdoConnection — он же считает * вложенность: у него общий счётчик на всё подключение, а не свой на каждый Repository, * поэтому несколько репозиториев на одном Database::instance() безопасно участвуют * в одной внешней транзакции). diff --git a/img.jpg b/img.jpg new file mode 100644 index 0000000000000000000000000000000000000000..95088d4d2be16a165ea09c0e33b4f64d8d25cc61 GIT binary patch literal 34947 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 z zW5c^_9kfLBxn4zFX+s0|sR?&8Bh_tr}o5!GvE`3dx@)GZ?s-rb~N>kZ`{_Ufk-*MoGO%h5R3 zYr(Y*iic%ZkArM&k?}=EsjSNP+sj?3{q>aScQ_aY3q__pgFd2udX#Ye0XNd#W-{mF zQbk8z;bGHdWT%O-yCuo$c(#1YNfbp4D0@*1Yw=+@y*UORg!C4v{snLtErs2MamiXpJ7`ra^ta`o>f0fH%(^DVEUqk~A@vc*YJDw0|t7b%Ukk zD5DYG+$r#^;2weN=X!J$VFnwMB61YS)6tUYcev7WeJJ9VGk&W}IxZI7e&zz?(|EHv z1EZX7-WT(ln%XqZiyObRE9b8lE6)rxjrL~mjy(tjWffR)H0b4WYs%lY`FcHH&v^G8Ic5G6eMZiM26(dEB~#>yd$X7t*VY>m5d=k~=L{c^WP)`_6S zXgz!*Dk1uaw_J|Ya4k&iBq){)2wU=t;r0!Q7V#4A`E6f%6Kl1Th(bsN<8{ zEK^*+Qe-qy#(v1=<#sV9P4Qjjps3Avf84wWucU%=?X~xY?(vR|Fe9?{whSKVoz?Nm zjieTSHmL-GF6;Dco&EE2 zM`~6@<4?ZpAC(Y}bYdqWO5avGX;q~PwmeU7QJGiLE06V2PRAyFZNBcHPY0XZr$dwi zKAi&?%>(MK_SZB9u=b7Po@=JR{t+9Ic9=Qr!{T3%jd)NU5%419n-J~CmP?ft9GW9j z5jWMa_Y2m&^1*lBwsw(Nhm#^3{y0e#!r#`k;Fv$Bz0y>5Dn&R~*g|PxQq{huyPTIB zZ5+?YMQ@+2t$3?x-#;%+A_U8pwb|$sYzu=6f2`@3e?PCK{CN$ILh#iGkftr+WLw4Y zBXE%f5gc~Y&pKGOrJo#g+^KF~w^uB0e~wVNGITcV@p+||g;Z_Zvn1RAab6A`AD&)c zV_cZgu{z^<3cbD>jog_&{YcrUreybv4?kAd8N@%b&MSz})E`*LzG(JFRK;482r-KC zMaT4he$FfPqj_rMS;7C4gs~Sj%W`b2bxMr9iuyl6B2d5Q9~kYx$Nm4nM~sBU2_|7F z2a{e_sZU|xPk6tHDfPcNMnDNP|JKj{i;!HeuK^RiJ0C3jqJfXmE2y`WtgU zVquhU-7iOOiOkHW8z=TCOfO<`$*{l)IEHSC&#N7Evc6C%D)(nu6&eIk4?L5v+J)DpBC-B^03`#!WL4@ zTVJ+p=p9?Jma%9_^FsF!(X_;`j^(c_wWLB7VdMOCf1y96F!~7CQ9An?u*zUe&vmGR zle0;Q@4c$8PVI%X?o1|Ym_%Ewoox0+!y9>W#Ux5@EQX@CXB?IgPWnNgwv*geSBQk^ z;|qg!+D?++>v=!z)nhcp%QQ9T<~1Q3+Hn?|hEkf&+M3XyQdwRmCUCG<=^k%nS5{i# z>wv3h!5wp~xu8|iHgdpNc~vt;R`fhwL{^{8FaD$PJ%wG*jrdaVd1wRT9*QN#4Bp>n zIVdUaSW(lzk1YMSdT5(J(~rVixw{5?k$%wXCJ-aNl!Sq zgj%q(Kz5leLP9p8-sla->3*YLl9@GVXd@GhMe{xf`tgsRO2e*F2i~M}wkpb7P9Kb) zajOU~()CEy{cG{W?zBDLBfNJ!p12M%4eM*|V-b3*&70)hY$}oYZ50GNq}-B-yiZS20qdFZ2&Ld2xR4Bl&M+RE8i8w`ckVNK6jQ$A ztm7{Jc9eRuQ0{?FK8ijdnglSRC8s?*a?0ew)Xhz(PQ&xu0*wDu6hEz-PNv@r*TYSRpz}!I-X@&fh)?%y137NUuZ-%S3r7cbaz6W zTyCjwoVfO!tx?{668G|@PFJY0AI-ZwpDYrs&05R6zV$AkfFCQ(mLaPi4OC~Sl>8Hx1r4_*%OZUIFXxjkho-?id6Zt?~-)dha*nC(JUAW*8$$;;)wn=ibS*6 zUtbN|#aIG|$1Ugw_ZuCA;BcrGNS>LLjLT7t=k4vOReO8LI zK-(K1`AMg&1$H9eQ|cGF;0^6(wQi8DI{LT8NJ-e?x3ojXy|;bI81!b`KXNCg-cobF zXE>fGc?MhWGR4%AD@XvCcpE!N>RJafKV07-wm$-ewF_%Cwd;uwd0(S&oAH?JnYF&@ zRJYXWJDGmm9OP(qk7csn{1{u%O0D~*Kk|R^R3^5X%j(PEHacm}%TWq0{9oDrf7uyZ z+n?Ii!z&&E?GG7@VE6O$1#!~g5Xzojkay5}q ztnndrHENo*nzFO;!i({ZbpIdPe|HHW7!is`U|ZA`mlnPrQf4g>V(JDf$w}+9j4HxS zi{3*_Fd2?I`~hMvssg~7;Noobs7wW@kgpaLK0*(-JVPX{8~jWc6q9UMXhB53Bt1gO zwh7H&sMg1%VWJWKDd}h7`qsH*!q63~@E#C?&=!6mvo-3iRnt-&5}E+_c?hm0VaJ@zPsoG{&vK}hE0l(L%7Ty@NBhPa!lp}!1*8>eT^T>U(h{=1_w=n6hs1Q+8v6Qb&R5K=hl8G7Y*I|s|)eW$?_y~96vY-#h$iYswqh5M2s^U$V z`>XgLba6$iM=AyKLtd&Ze{gFNfki|ma`gLB=OAWfA^xUH5sVcL^GQh&(JKjguFxWW zvT_ESWfHo1mvYk>s_)}1I-|2hx~}4ci;RqKTZOdo2I-e^++jJU=)wBqo>|BGwPVMx^nd`cc9r-wt}S3$S3)3%B1& z4-G)o77N&&A{|0cY$tX{D2;W(C}ZISOUgpvCIMfi^#@NQR03e zk|_v%4(SWl1F%XY4&aJ%ftHv*N2Ng?E>{Y=jfwAHt@f*a1Q5RlnY>`etjB+tg1l6} zy=6=AzE1-y+;pKO;lNU&e+Y~ojnUWWikqV~th`Z$58ddr6!#RXznXzcs%w=X*5`eW(iW%miPK^4}cs139Q zOfSXo`z5MSRRYSy#9_;xC5!evQ^A5@;5*`jBprfn8^Q6g^?BVPq8jy@wxViZPy#aK z*^78-(}z^@Ab4}Nu;tyLB=^l#n!ja2M-k z6_+tevQ3450N$aahpOtpe)!&x4?BSg{qj~3-h$_3Mp)Um#ECo`wsV6ssEqaKd6nEv zuNB=qC_qfZ?f3G?vHI`A5a@SkWM2d0-+}9JIP!FUI%cj6;#b*w@J#xe&F@g@2LY*ZF8y3A8Rn5t;Eh&t10g+gzP{DfG3%15h3biVl zjmww@EH8&WL}Xh|JBm5Sym{0q_G$Wj3_y}mTY_+5U7x@~P9^*$NWGj~wFns;fJP!( z#eV@EzxZ)Tcr;riu)*#ac8`Ctjv81;i6tgxZ7`p~7-~7LtUic(5~%_gBnRmBm($vK zAr@as#e62i{X~9>EJq{yL6?W$8BC5Onwee5wujh@$+3-u`q}6a=)hC(VUr_Gaz-^4 z(+t9+@iZmJX2O4I4vfR?!C!WB!}{Hr;Dkor>YbLId_>+o{z z4uIoImJry;(x@6im^|KGNA1OdnKB|?w`PJ9;Du!ieFRv9JoQZ3=~c0y8t|;V$x6BU z%m-dzYao?^x4$dNR7-fltd&;-MqQISW zQ7(ld)7Z*`Wk+3hCu<^5UV4h7pvaw$^}khDSKBB#tHt9f6UU=uZ_-NTSsz4) zPME0)UJ275rhI)a;omVQsIp~vW1WaMq18a|_SMl%iuR@O1QIl(f-PJ_fwCqBeJuV{rlwdz9P#BWLC z3SWBXjAU)*ifE~*VG(WGui_T7Y3vrk6yZ8DsA12OwMZECiq-sjHymp?8_w@kkAfC9 z2tzu?W!ZMp@e`_iCtEu{wF$@2u6^RNNV{?;o{=d{Hy#oF&88BPk?NN1+_OkZ2CaR= zP#%FFyjQ$eEnDkbn~wmVkiXFTV~0neUt~aJo!37*BcN+cW5)+0uq9R)@y~FfBYpxh zhz8VM4aWTOT7yuJH5f-IXnnFJ6Ni6n3-x4$GJhS~;BW-B3z`qf^PFED%Wac%zuBav zq#E`Az|%?4H-8UJJ%fJ+55;;MUtL$wN2}sLovGUVd$6j2-RX%DzkprR3rAJbG?EHO z)fvhog(FR0unkR6*6{uj;%B@;3~>kup?rPBxC@9I5GfpEWY%u-fQ@oL3OLUIF|uqi zI1gIh_y&e`u2@aLxX8-|9q`nWwom~$GV@l2^2rOB*e2r7z*OnR%Us}k#p&ym5a|tx zG4j|~GvGklJXi+xfEbd?)F#4;oRIHvyg|HvDq^jsBbrE->n`D_&OeWdeS!uzk-tOm zT?L#?)74+U%;Sk8^&qa-XN4i(rO3__)GZ-`9^Pa>#?5=q#?6z)s>NvX-RT$viIs{B z0S{txiUi$=5|8 zu@+#kAWy5;e~F9*UwJ@A(a9Dcw()Bg>bnb{OaI1UTmmEwwgj#-_w^pCO%L69Fk#&d}Zw` zQC&8vKI!4r(ejIkWeC+6sW#nF7VV>9N#7J%6!9Q)sHE$&AX*7TQ)NLP33|BwIe9YmBMm!@C^opQbIDn<)dHVqV5TI}VQn=q%-o7a}K$JWg z6~?TcNHP$UE6-S)wVz2>^w4iz4sdui@NDuWbgcy}@pEeGaKnlZaxneUVYFrZVsT~v z3l27xIa`*(&8gyEdT&Ln_#Ih1E-Z(Uq9>dm&1f2nL96r5fGLHs&cp=PGVI39j?nl0 zx4-5t*UEo&sjvB!cHS#{-Q?b02wm=jqVn#Y;zJ`OxW3oiEBpRK{cq+!itN=ZC`;zEwl6KLXF*d|_@XUwJhgVr52rQ4{5r<(l`iEpKP zi{$f6R^vnKIs8GfhN}iBgJGvz!Fh17xT!!}P}v=3ML;IGHh`>&_JI%!CLpAkTr)@v z!gfq4Z9>ci5QE5i%$OOJGIxH>lC@E116xD5@<}$%>o7oh~<4z zNk$S?=^6kC*|B8<7p*-E&|>D*Shhn0J~WEEt%T}vT%5cVCHfAS29Tzn>vpTiNTxUv zaUVJKczEI}TQWfkGi23vvEc$B;BKjW@jIJnJ4Cn-5vE}3FbxD2QS8ikGDY#ILB>NfJ>@Lw^$Q8Ki@@K9ATHH=%%BK%I$?*6?P!fY%bevi@{!oid}*kojEjDH8Qu5aDjFwri*M*5ZhvCDvR)sO83Z)R;EC#pwmnC3-J=TQ}0m8eT z6OUN){e)$IJdDqiVius2g`*<4{sMRP)hTl-Zym3!5>G+@sN#)}e}jApTO*{48P}%j z?YN8!lG`1gBQNlIojWu3J6QHx(v|7-8P|fXK)QU?ov(}wH^~^y!!sj{j9=VwUgor; zr5lXLa8dY6>1*r%hpYDhYvKv|$1fp3=%EJ)kkCY$bPy>CU5a!BM0ytl6cCUSdT&ys zC=i-7r3ea&5;`JP0Rd4$M^pq9X@bAQ@B6<0=lMU`yX?)*es(W+xt-aW*;D@1nE=ky z3qk7uDK)yl57)tTYKLHwqfdu+UO0s%J@Z-qUKFA_6HLkeJp22~YJ0?I?v4fV!Ml>N ztg@wY)AKYeuUg9wPW(J3%Kd~Vkg3|TXTl07vYbgRXL8EF)TK38wqnbEf~~3lA;)&L zn+#cUD$C1yIOuPIyFzmc7?XEJ5)o3QvOkM&(osZKA(0!_Pjby(og1al+Zbyr+V)cK z)v1TEX0vRT+M4%}TUpqVSqmuA;e^-NgsI?z0I?N56(&W_nLm7PqYiJ_`!HsTZ1Taa z{yu*^@X3aTT2K9TA=@vXHz@ons>3EVw4vgnpX?_2M>`S@ovDkzB4Eb@#4uiDxmHqB zEUFDUJ3?>lQt}`71H&}k+mcO`WV zxuH$z+RJe~jU6I}gR{n)r*Xb^n&0ndVzok2PrZ5@=i5{Ycq7Em^_jqMrPt{q0|KuP zNL!S)i&1w5sP5JP5TAa6ZTclt9>$X*D*lIm21Y*Jm z731@`ove-|)9VX3mVQsdjJ6(ALZJHNUirtgRu-olpFOv3i;Ka{NhZ1qKgV5ej@$U#^fT^Sea1&0}>+=$w zJQ44(Nzc7V(!3>AYqOgFdz(h`64un3vA&SGk;l3InW(;9rq{%S&=Cgl#gW5=d|0-C zXu@wE8f2P=;`b!tyWQ8sUW!VXe5Yq56+Z-Pg_k8<)pV|oGL33^Y;q0l+TO@)1|uS{ z4%hwYx39SognV)o(&oR6gi*W^&Zb_6G{al+fC*(iGA5R)M-FVzx`sC5_0@Y5fG%qY z8Pgq`nCnUT#)}UD$v$nj)%o|AE%@NQQY%<4CXp@Q2QU`L56r)vorkexe06r?6#)D7 zxHPXn%;ddjshY01s&;ejGLectjnx5&DITiTM3<;7`2L|jfv-)7w#k^wmjRF7NT_!^ z`9c5KET6Tf*@vVEr%u3B7u?+3){B=npq8d_mQ}eI`^MwZj=`jn@OO zPWSH7E$ch6ZLiK_9Jr>`jd|4Hd|Xo1=ZTnY(HJ84tJiJf>rEQ4V@+ZcvkKO1pw>!a z60>T$sMNQNWR42{$|hh(zVemr9F+u9%7AW@q9s_!*SBcZxC`NO3_e8u`|$31a7OO9 z`oOzw{rCwngA88b`aNW^E}PxEtIK>!JhbaPP5Be$+Rjq>%iyj&U2*tKA?Y3g(GZR|p)KRkQ)S~%>f^DO#R{_zn|k6!#%bhamC6@2B$MO|@Ucx|#MoFQV69A~ zNc8~*@kly)eS;|@lHzxfrG$8J&~dGnXcO~1_8_D5V_7?^hX{S%Lm{9 zS=yq+($AUeLSoeCO?KhFXjv5Q0+Y4@dJrKy6B*;%6%RoJL98u$@B%*5K57~Y7$Wy0 zd69_lBdg!=LJ!Ll);tG@b*^tiSxL+Xj}^maK?6c1AVmw^)4wap(v|O^IW9w!*XAp3 zO#=@U2v9&=XIO0OmSgK}d3teC1m9>?ujd9h7v!ea!emqAN2Q?n3)eCWsY0*Wze#8H z_Kav`r};hb%dMFHr@mMyT8RBbE&XT&oGx$!Ev#R+e9kajs@rABdIG{r~X5KfrJkp8s20tQXIMOD*Z}0H|qaZEavts1ylN|lc-H(CZ!87Fd;$yb20Tx=s?{kNA3lW!}Ya)&B8u@py6t0 zHL+Sc>h=y-`!?Mb5GUk2mVEa`BnDHWA6y|Yu%IdaX-_Q=@?cu>-pa_}Wd;A+Tz;v` za;ZvLKVv()kcMBXgR9HdQhVoP??)yPokr<&L07S|)fdt-g#$Z0#_9mq`myzCUSw;@ z7x!bUD~xERDnenr7e{?;8+A3I<3?SwdG09b zv@Eh3%p8Lm$xm{r(}o&7U5;iaEq?(+?&SaF=csmTiXBxZBg{gXPwM{yH`M0Fem^}C z`wQr+&2<}?8INdCaTP-l$WLk?^$+ITs|)-8r|J!kYDfOa!$Mh)g+H$IumL8RTxZe_q!@SYzq7Y$eW$dSq3mu2tg$zkSW^uM!=EC;~(pTIwXJ26(Bb{ik zr|@7L-~n<^C>Yr=&c4bqtYzdIUN5Z;&6#^y`arX~-)tr2sm>z_uXLiERsRrKN(!^N zmrdnf5?4y-;`i7^iv&= zXuSvpu&ri(%?mu*oD}g#e}SWoKN_bKJ)of%T)B-5%1p)#00@hPYd>tP2R;P06dUfd zyFN$xmfQ}h9JkhH^^bq3mCOE-VJhVQ{K@m8K%Gxo_w65pzU<$K0k`jkeu$jh>&x=V zTQK|!gc(j1Ij+6)saWu3wXw*{cl|x%bEh~YZ|S?*M9_*l`;uAQcg=4E+Mj;3#i1Qv zi7&I93qCE-I5ah}!A5EBph9kgqj00}-)ZIrCgjH^gN92atEiP#OUIJ4_M> zL-;)E>62Ta9g!`sx!Xtka}0HB6}8i&{QSG}?wIoace`dI3KOFav+JzZfosJkAM!#3 zm<@%p3@NT^v6}`8y(zI}l1bb>IFWp-yvwFZIcD|8OZjLm;E=1t=Auy8%OBXc9Djj* zzRUsEIpf%{u$+O#cz}^|y`JA4fv^^e*qM4Waq&|B}=%@xZVJ_e+qZFj%|3*r|^AS&-HwN7nJ{p``6uurTVP=b(HV+ z$=)8wzy3+=On5Lvg$$sa2RN>`&Sp&9_&M56P~wjKF>R!Ce?{fA`RnI90QloI+U*JG z>g+fC;8yC6I#4?;*xwF43h(S%D-djb`NQJy`)@O?mTW_XJVUv0Ls?6$0QSo+*@hpo ztj#|)k{g4Vu@GOqN2rlr9ezdA} z9v>Tlmuo+A7$k!sV)hFs;5DdV;tD_7Z2$?MFR_8=`?LGiU*P61E``Vi+7$bRpNH~q zCEP+c2Ekus>o1^cwz(bxqWlG3%*?&LYqtuLPY*2?)>T8oJ2)JT#(tDIpS&}Bqf&4o zBYc6q>4W#K_4BEaX*0vhmb!`j)?D`xm+@gPZe17qaJf$*?j?rP3qE&ddY!qMH#TK2kY(1f+zY`FUaS5~`_l}{mTw$>{%mn%L6>cDW? zEZ{9~(vNZ=XYr>*NNJ-SwNNHHijFXZ<9|_W%n&sMcy8?UGea}lZC~HC+Z5BKd|G^) zV>fpHG>Rxw$m5ofEvsBWSQ%lE(OB~gkGb!!Fw9KgcqCYT#Wwp;C_TipC%kHTifTrWF3sPW;cA8E6kqlTVw{ zn?E;XqVi{n(T25AxF5x)QA^k#dr)I~x$Xz2pDHmoN%DDRl>jfIH1wXAChawJP4@!> z$sStC9tJ>?g+5m}PzbNntL{Xf4cXAmNa_B{NoC^GF}uUre=A>CPgqPMfB~6b?ZHUV zD$G&2p*)h1r}9zb8mpL&)#4wdiGYSn={%vTsXS4ykG7JHd^;@p4v!w(LE=RZ1yyWM zH-$E?mP}5-DMl2@foY7TpD%gBuSC)qYVFgk3B$i655Xo2qu3$i8c9t2881C9K9_cR z6GK6@=)M$&@;{YF89Loyw|a4A;R;NtH+1^S3?7g!oKityeTfi>K|AP5Z7lW^gqCQ| zfLV>^wF@yIpAfnz*d{37@qSHDQ3nHG!cnuM#8chbyB=s-C3OU^54xDL*FY*J>aMFg zUT3py;u>UPi(_K;@4mf6{TCQ6<$v*VbnAD~z>Idwh-cw@hVa?2u4EY;dva}jAty&p z0PIYbA@po5ZDqk7rmdz6;NYoRx%`AK@+0j0-(fzs+*t!Q#F z+#M?NWK+&}YiRL%uOh{h^)FTx-sGrC`U@m;3dxe z77JG|WSDGxx4tUdh^grFfEzar9AQE5WrpPTqsbn>5n|1TJ`ywj63SY5(Sz*8s&VRK z^GnSB&2$H-C4ElgjiETspqij#zEnj5Lb%`+w&^k3K;lG8E|SstiOQmk$rQhwu9-U* z>4jKe(|T?mkX%@#wAi?$gyp7lfKWpm#4g&B!^F9`=}Z1RS(k-Z_Zd9B##?lIozC9< zmt9EWdAx~u&92@2-UX!8jkUSkQrRvaOh~UoBk1l1k2Z7?*rnW<@t$|DUa<+PC_qAB ze77HS3yCmsPF*F>7e~!XAT%^OB1}^gFVcQaSsWT?66UkktL}2)@sxG1n##>-y*$B`v7SO!+~;Lhde3&BWna@2jlE(vb}W)L1RP@9#PY0 z5$`We`}iR`2`DYh6r_uD$aH9e$pY#oaSH&e!C&Bu;&N43*zWqW9WdxhySr}wI^Zoh zj(`frzvnIV?0LIlzpDs3-bs+xgT6#+{AEvSly8<#D{G3+yfQV*{wx;JkWL6fKGOLM zI7aq|r6itd`0U#xf4-d@Wp1Vmj2Z{&a0*3Q3RNV(#SKNCepO6c6vXka{&F_6T^Tgq zm1_E~&yIf_9XuTwJeZmc_ZE{U)z=I0XuJb8^CF>9dPS1d0O~ev;{YTB-A58zJP)Pj zD@{h|%-=oCH)wF6>~*+bbM0C}LgH2~q`J(1^T(^QU#M;uu~M2`c?-NKcN< zl`F7sLfE|}LGykVHVgTNq->#t{hBp6ywAZ);gRlA66?etV7xCnCib(VXY8HlCdcsl%Dg`qtTMA8r5a(4;eCbPDJiL1KEta=l2C7enInpiUom5&<06hsg3) zo?i>=d7E0s{bt5CKQTaQ{CGLXl}gBB;BdrFWZdrS0a>eMSK4;6gn;5jQgu1)9WFVl zFio~-gin5V0yP$}0>~VPByZ7RO*fsWnFs#@f--AWk-w_L;2Rf++mK(Co_g|+9P}F0X{JW`u5V; zS|(&fa=JOT^lJ@|sfBu^K?EkxuPVy30y7EN;WbP(%JV;3ePdxfpzK5^XO|O(P&CQz zl$Je1dtVp=UIlm6w~`ux_bSD*%oAURI1(r|cvpUh_6J-MzNK&=%jW{={Uf?(TI4Wg zV4bqD-GK=9+fs#_r6eyGvdBH*qJjQIL>TWR$?F_pxtoKCe-uk0b+|-s>U}j-tcu!C zd*%#qE~P1D_3+A`+e3TBwTqiayfLUy-Eck*vg6)9x7L(wYVQP+N%z%utG$6XyHHE- zax$Nb@_$C#%owZGqPK0}Xl(ex`=9iGzE6BT*f(6*mI(QJxv3X!l_kf0sC%b`^yuLH zDYcDZls~kO>Vc8)7r;eh-Fm%MySzjE_Tg~TfpaG{MVh`!07-G3a)If5@qLnmPrb7n z;%<)tPj>&MKYsjVr(J`*BxT;efWrUz&m#W#My+Vmv~#|pGeceG`$3M9K2B6Q5c2QIy|%7I+vhfBvM-|D&Od_&YNGeT zhhmM}h zEekbMs2z0*@bC`ruK!Z}p7s2Mx`@v0dBFr>c^!`>nEh{607p;}{0qQgv6_?fqVFP~ zH4-2M4Pk*GJ9(-}8HA>3+tiYPnnkv5MbS!<#U=ecuGdS#G2OXZy~KJ6iw!@E1R4A_ z={O&=RKYIich_wU1__z`M7h$w#Gg$WROvecVj^X2InVvgdq;(LB+09(a8Z!9tMsdc z2S=H`WV=G`-HgYVdtlv^br_~c6%~4z>XzFcJl3rmm#@(9u|Fx-Y^Sd6Un-I0sp7VH zM>ghbSY!*abmWf`y?3RHb|%s5qErV5&NyyHJXH;yk3Bz*57ObZr`8FWD4OoTI4VHoOj)67|`y!z+zg z@fRV!DC;|YNM|`fu(LpJkZeU{k7A(c^Om887eqzJy5N_nyFN2}_boSUa*DEeT-!On zOD#qoUo5MXl!4>` z!(N}enquA^OH;Csig~wl>DZnns}U8OzB&?|APG?_NIu_p(>Aox)U>^o)ZEpymhCUd z=qnC&_-h(=@&fN{KH`^|b_;fdI6d_frP_Kt1%-vC9vRwSq=1L?`;& zCTtg*_2Ig-hxZEyI>N?6Kr5&7XC}h~9){e$VG^m0p5cMz4XN8ZCq;iEoz|C++0}Sw z0)tOSKC3;A%=osjbaO``xH#olh2^)|tdXlq&M&hGBP|t{voF`PFOW#fv0ZOv6g>S#@XTNnmosD!aI~~(HDSmrKzHo3l=1La;^$>+P|1SReAK*U@u;M?Ce-&ps zmycEM|7Ie-rf8lW(*m>q;caw2orrXA{)d-x%={L}C$i z`ET<@?sjS5Sa~?QZQ=96bXd&|uyF(@u{bLok+uj1?bKI#`zXbE`}FQm&@Z4$G_79% ztIQm*1bijoEp-X@!y8jX2&WnE+y-A2Hu*<6rls7hnI|V>4F}n^}Gg~3q z9e9sEP|VL{Z*3$Zl|J-9E#M=Rp#k|z8vo6ff$Z(=N5e;|tD0Rb67X@NgL&E-T#XMU zznEsVk=trVOGQyHU4<}(%0_*IMjO~k>b$y$5d}Nn zM16{$CFaGvn4#?^Ve6pEMr#XD3I}qlqe(FdNNz1iV72s>i}}Il(`MV)%qc5tmKx)w zs_wXW1lncRSZ95Ix4$NTi8tM-mctP%Ko{%zE_BT_8216o%z0O}qR(lE#%B6ic3r=G zQVw)aEuYLQ&LL5Xu~d3q?N-bRr@_{_hFGz7rJI>EiGf4eKXM%OSrz<_4QO>mV&o0J z8uqVLG6_2&{9Pq(J39{UXad zPYqwJVxNsX8|`A#EWOO$DEf*I9&{{v*|ar^yuSVY z+O>&&;+J$RZ8o#KlbQqf13q&7St~WJuMZe6=zrz zAHQll@5j_%}j4CO|d!Zd#HzvIoL8zaOwM- zzQ7=@HK>s`(^&{Y&0FaMeXU<9RDJsti$pjH-%YB?);KAgm6c9&soW}&*&tQkcKo$g)_;1>OxiXg_$tIEE;FQvlsQoF3p?$ zDr2za4~saW{yGqFp&{>snz^B2&?>j$;Dl`B)qls&RbkgMOHl<1lsuQDL1!B z-nkhcGT?>ED36&w-6YvGnr9H@A7u==FD0b*Egd`JL}ar@8Kw^`(W{xa>AmF>yTi`e zFX=S@7w|(=*XgCa3e4GlBGR@zohe3%l|iae z2+=(I%w?bpp~}&yC>|V-d5ES^$|qIVSBza`0r>)c+!XueqUo9Ye>jWTvGVfowQpu4 zh5Jn07F4(Q9jMo}XDY4pu|?uqmYmU#$zP8Np1|0+7!sqOruNjv)w8_2(+{}B3f?D& zCC!Wkr&@WtKegBxd5q1Iqn=qKJ6EvywnQ`vV8?*>wZGYBv?UPNChQEjVkv~E9h8%m znh=!=e6crGm-D))?oj^M^|QT@qJa>z%8PBLizEu4-=4zViIT+HAM+ayPT$N5yNn4w z>9j){(I(U)3dNU1epf6hTXxyd$Ii z0lwyEnip*bx#1kFId7gOOpz6Re#eaTvk9(_*qxUQGqNsz`iJ!S>7NxhU+YtY#isnc zdB^LuM|!PuPc&IAN_~6JZfHlu3e|O(Oq&`g;Qs}DDkxg-zX*HFpgY&H$9Pm+re-#& zvl*IrVr0Tw4bgX61Pr4}qr+*ZelXU3=D*sZ-4GmogDgffur!tr*CyI>06SqwcLLCG zEv_`z#M{p#3PPg2M9d%G9-WX&YB>9krDRMYth%{Fk~hCz=(Ny(VOnl?q3R1e_`nfbvy|LO-3>{t zc%4_Yp6>?4!D{}gIMthQ&upVaGYFMN+UhiS<=Lg^=FLt7#2=<$f;#Q!ug`~%Ic7(h zOZFS8mNi#Ua9j{-fcVqQ_m1Ip_I6NVyi5O$7^?IaH9^O7GSCx!m=fjJldiZZ^ReE4 z;7}sqFz;b6m9Yu)B&xlD{eco316L>FE#J?}{QF2Y<};uvI~fv<)_Ws!X1+xzwr5m; zNQ@wO6u8lYD!$@!#wqo>QGw%pL?p=fZ;+yi3L%}9{7?GN0X__Wx?DHa|268*APuwR zQ_#SHpzK`3-M~@u_rGt<*kF7G4aIO>>4t&_j~sc&`8{WJe!)+pz}yim5(_f?`o{ld zM*Cke$QKaAT;BMQJ9gvSIUPr(OH>mx>h{nXw?|Pbh{9snFLkeA=2=jL{FgbCB~|f8 zniz==iaYcFNM^+1L?f4TWuR0Evi}n(RS?CA8dxL1(v$%!^eF0|;A!X!1%b}$|5E>7 zY7I$U$Id`1$+|30DJ@UREuBzRA7z zzq_sfiQ4@(wVd;`2=~t8}Ta`XQs2(BdU6wh13#QVaC*{|?TX zu(Pt*vXc*%tt0w|G0iGfQQx`v=R9~OnpOUVr2YOEj5a5)^PFw4fAGH}kU?Ij#XG}e z_KdA6>pPdQqUt}wAJ3|Ap5IPX6 zL!R-Ke{H(lRkuFN5C+e1K%R`O?|mGlcXbAT$AVpQIm`T44aDJ~Y!S@*KD3o7!^6(2 zie}rxcL*@&A0)%NcsGTW4H_tU`B&KL>;e^u#&Nuqs4zT^Gilx&Vr;|DAi9Jk=SdLW zeMyXb0-?#>QHN3W-U>H1DJrdU8P~iW{JFajp3s7!CS``+W|4qQwZ*JNlbZJmNuw~nl>T#ibhVIiZoJ&$C{SL$rziLSamqgGI zuyDas38^=v)>wt4vqMtjE|zmPG} zM+)K?Uv_gT6T7;U(mMSq_&v8~Xc2toR+ZIc4|oJRj3>nboP!`zOwUy2LFuAxJSH%; zudG7CloBnJbtd+h%-n0j-i>C7uftM_Aj)}f+*0#%bx5Fcp>9mWSi-S{RK>?+X1#`k zqWP8`qpa6e-G?KpxUr-61XwS1Sr~Rpmy|($5P5t@yy}16ro}|oxVZWC%+1$ZEn;tHsbS_>FLjQ($l59iQ5dAF;$jms-WNlK6h( zHBlkij(q2FU;0a{)Jtu(d`w0o)E!o&ouMr?RN2`gr6juBRC7$R9UKmgVy-Bw^t_o` zTHLn~dm5aCdg%^AI9?Ed$w{(^U-{l|1#NpSY?S-Z<-P#p6(ITkb%IAB7Y*xW4BCs1 zqQ4Dw@eN_-kH)}8%myk#0TM02Zi!W|Y6{}9fM4C<&DCNWub+HZ+BxZmG>#6;wGw%T z#`$$V=(%cC@RPUtR~benIGb3gNT9ArEO@LH&$W%Am%W3O^~=`yj+vn2 zSjAkV_a*V5rs$@K=VA8CsfJVueFK?HF?)_U*v^zwThZJ1fd?P)X=rMqtD_--ym=^z}Dl$F^8{C)auox zVNL8aK>Oeo>2^>aU6t{T$eeGT7e{VxhdOYPmgWuB3GB|d+U;aVl{2KOk%=eZEb7RI|WttY3RRM`5y@%GqukgI95_(vT z3_*&FHuIb1H8)6eva8a6HoyE!DQ`lK2zM^L*QkIg9S z_)WK-M7ErNUS1T@l|=<>;a0$lYhZM+-1igF4skd)v4w31@RZf3za%HPe7fQkDiWe| zKzj^#fMAe8DoS0cMSMTh5fs!f*e)2re5K`nyg%;UO?lf0hqU1)MHL>2iigf^k8@hu z-2d_J+6oC*jVg(slyafnH`~-HuDKL=3qbM3);Sa zz6E!8z!wE%PiJ%oKou2?ACf!#F&oI>Q3>ApIq=*oVGDxt3;rzR+yTk%FnGA+@`L$Y zaKWYCH=l*B>`329%I-i7ddFVRz7vP4Pdrnm7rf~|eWhx}85M+wGyLBT}K^KGl zuM0mucj~S?8sP@Xgzf0;_}spqBo(z7jQM`!FZj+Z!>r&@@Dj^WJg&BLHqk+A@dIv5 zc@Qh3(%Ezy++>1Idxw7~6+QnA4F5ju8~Jz)tbHc~BVT92V=?7Q9__^oGwm-mK{~Sx zxPf5B6EZ4gZr_3RB?iZn*v4o^?QBEw1-bNF{kQ48H4Ba0I} zho2iO`&o6FImUlTa}w^)e{vMwj4TPEFa#I=&6D?UJw^;D?wmY2z^*_7R{a>59(m~q6A zSmF@mb&KCm78FgUIsm=g$SQ;|Q(;pyC-?Ym zN!#52Mh=2cZgx5rGGhv^w?^$vcUnd<2)GDD_o`FC5qY{U4==|aE>M~K>_d@QW>qVP zMN{4Ef(FHy_y}R=C^_LP;%yJ`?qw)awSeK^MJT!nF)gv(Nfz4(-sB~kRY-}sYZk|n zO+?Bxzl-nm-yQpD9s&|kQnXPe*#!G&P{srw+vFL{HaJfN$7Dt7O`bhp*FH0Z?VvF^ z1noi=@%#^4+UcVtWcG%!jtKL3)C)(gTB+XsK{yPT73I0|7oewmz1VL{YgDVujL@yR zwin7V8M&mhBE+asN|~kNcmK!YFPAJT4DIN+J$AP?K+C*9YL%_Z+Kf4~A3vf>5t2X^ zFD}K%Nv9+&OvrfWH!V*t9D#2%?jOAWsE9(7+OKCF6u1+EPY~G2AqmM#V6KeH}(N$7y5;-zT~01t(3*?N2vY~oq6k8jO2r|^1P&@MB%gB~$iwqZ?^dsTO7 zhxu2;G^V4qu6e1w@%uilDxU7V8zrT1W2~cAy?NfOJ6~*ra7%=SE*>CS=n$j+m&H;Fp^YmZ~oJ(Dhy8k+)2{bS`bYEqU3s?&Ap!6-q=?x5Ozgbh&7@kocDIeyf%%5dYYi|iDqAUP+ zJkE5ERFW}y2T#9lc_$hz3lo&lNO=6zPDC$~d2KG@fxVl;R-1 z@)A-&-xV6W8p00^h0l`CkgCaUMow7yD)0G9V(_eX(An}chM6oL z6J=86nod&wjLE_ie*7$a!eyi~<&@of-TCNEx&iAMi)on|8~LQnycZYJLvNX-klK(T zI8v`#{;gsY;;BxrKPn~01}+=jM97Fab7?27kbfdwfI(b<{=9V63o%QeMtZ}K-&j!; z7Djl)pAkbkD5jSv8~1#HgRD}~)2m*{+6jZUB6f~SFF68I3BY~#rg;vH%U?>zX`Mo-ChPw_~C`8>vfOKFhjC9>qD7 z)19-_9TmJxp30&>i8tqpep1R96_o%rgVwabu4IO#I!1M~a8+~J8KFa}BY;eb1%d0* zV??uV3)mWfjNhmh;~di_q6%0&_sfFYMerzcV)Eh-s$%gqW+rDQS}u`B%H#)fS~G}f z;J5Mj6Kr2x(a(3Y)?bn=%hbcqOMc%6+|;UcbpjZ$vKX&I^$X`Oy|GG4evM0W04!6k zYB@I;Xg)h|1!$6E+twJ!T_6l%_Y1rq!4tG6D5Feu&zouCHdhp*u4Ms%4x<;<12A7R zr(~vS9J9kKium-G-cf&EX9ApXebzr2+HW)rKLX^*+Xcp1g*~pmHM+V%yV=p;VrM30 z$I%3XwDB!PynZa?w=H^5HvlJC4-(>yf6p0x&YFVk8P)xDtu_@`b~P?phsJ#~QmW%9N!l3wQlmM0P7PiD9b zfvH3A)0B!xUxL;>+)D=nd;zQ`9mni89FYsw-gikr}(C_X~t- zRs7h0$#yaJs+UbMJtUoYyz0r0QpM7 z(#^1*ODb96vjXIS2GSBYt95A^MbtGqvrBK|NsD3vHw{7%es8Y%?ucmg^4_Y6HNpt8 zEwR9=)h!4t-~iTkf)FMlS@S9N3n=36gi$POAil6bkFTslkx%&?9AVUoAwquD*Ih{1 zDkMv<_?$)hc1(2j({?uHepHxtQtA;Up%7VTQL5ArgJ*hbKWkxZ2G^F@faj~ane6+y zNpWjR&X)8jr6Q5nQXmocx|NtT%x{#c2U6*EHy%jG%5|6&?Nh$mOZ82fdbStVR?4%d z0cFQ^^Kr0^rscYvk0$?C(Z9h@h73AhWM4m(0AOQG)Olj$M@9zzr!4_~eV&?ciUM+! zD_cHd;y!O`$J^j0DfZeVj$&Usyd)!XG28L}l2mrmVvRQxHY{cKFdwdBZqY^`GPFe) z6@=V4Z&uIB1A}@>G{s8R;%QIu&^4m@FsIbhK~83GxWP64DEfPN<@?j;B&X*--;bT- z&u!PdO6S4}PkAR3!OJBP(%Q)E(tc~lNvEdbmY50`r7k!uGWs7WYDbIUw6$O!lbzjV z8OCyu9!7zChL;tGEGjMI+?pVlW9Qm6Sa`>E>PeY2)hXo`5%a@}hlVvxbnb%=BC**=wu3$=s$ONxy(l*dQ; z0T?tSIN>@b8c-0SJXs4P3?p>SG+{ys&Ru>^z~Bhkl5cQPu~62IEJM;OFrU#A~5-5RFk<6+{5h_`xcSx6Tg!t6&&rIL3S%tPi+^X>mg*FOYv}ERHi+n1jzGa z^M6`Yu05*ky?k+Gf*`z?#*qpJaLI6_%TS$7wyM%Af{U2cWtTC}tbt>Ep^;j9#eZKL zk?sGb%Ad!@1IG#Dk$&tf#uQIy{sOlzukXtiN8bLQEM?>Aded22%O2bEWKe4hPuM3j zZee) z5EVRbI$a$6-px;`!mtmfvxdR;RWkms3uoQJp zP~I&n(+4G*@4uSVZcb&oYbL|Vj1lVCH3rOzt=P=jgqNT7Ny3HYu$ZktP5a@}-Lkr; z5o%W&KT8DTJFB&nPL;XQy1%;9gq!?zarO9Qrqn!HN*d;a*v@I`?_vSa!yweg@_V_D zJ6!+F8uXfS2W))KtMSn@00NT9n>-Q^PM=)+HTQ%oUf48aC#+|S#+zxx-4sn@osD>x z6V1@`eVW6a%Jh-TXjMndgW31X_($NnNP=-uW~@fNf}fjG34t&xMTC`PT#|ro_U*xt zG;om1RAp*;XyyaS>T(HItoC}M$tR-rhL7A!T6Sp?35}P%fQ4I9q!f?50kxut4khIf zUK^U&*PIN7aDiMFsU#f!O#_K}jrZ0_^fg5ni3i=l5~=3I55>;yE$$)pRJn^{kV?Jv z91WfDIBm9l*OzGyJzC?fRy5CpyZ0u81}LzLMi~+=zIP% ze~?PJcRji#^c8OOcl&}}&fQ_D;ij7Zp&n?aom@dXTBZgRKV0o4ZNTdxciE!~{wQ;E z*yJ=MU_mQE~1jhA~%V~YN`TSbTg z!Kq$G;Ysww)eXX=BpsDKdd2Uu2w9@t;Y!GfQZL1e50$Prg#Bw@+D}LkuhQO#d7L_U zi7Dt|tl(F6rSb~NVOAFB#Dr%UaOI$;Axu6*YuECkmX)OKbxjh+M##qn)R8yfsTX#J z&pi)utBP|y!W#EHd=*lr7OwF4LO|zz1=e1Lh3$X$$rJ$nc*CiMR!RQ(69_HjC_6tj zEonX!ZoVhV+2yFG9`g7Shp*WlAV@H>gvXP|h&>)zBLR+Hl*!mwb2gh-MX&HHVc|+$9~$lY4)DL_q~TwW!?D_3 zy<2@=j^g2}KYC&+6vxxzBm?nbthVTh$I~NH!`RDHMwae%DkSwlf^C9oJDeolUt-&B z|CQr1_4cqqNI_iaZ%}lAH3Df-Gf_ML)A>jG>Yr0yK z^M)R6DV>l}7{>fj4J8+Vsurn#7WXX>hG8}f5^}rVOHhO81ba)j5z9)|CN*Pih z59cnTDKbgP1$hoMk-VZg9A4nf=Xh(pOklX>)VHnU`;7JMjVs+xU-EXXolhkfdbvtr zEUaNH20>^$(`)tU=jW>f28=fu@EyzqR1Wp!`uLri`U|W8&JWkLwA$_D>~;g$uEWTH zj=?Vmw`)pn^QMrLIek3JIHO~{7bEmMd?n&&kK25;$%zyU#HRe`bh|y&I25Fd=G()i zd3gEn(i?+r2juWkFLdaH7R+wxK^5~PbxJE)VLs3Lb3u82T0#t6Jvshjdo7L#*5gRfO< z>4_2X2l|BArpHA zh`>d@&~r5j*SOaxih6RN07eRxUeja)xO0W+k+?gF@ikW){hOnxgvXN`$ySVKcG$Hp z%Eb%u0D&dkGz{hgy0;h%5!w@a;Rf%uA46*F{RE$$yHR<&fTAfVhv8G3DE+WXQ$c3Y zv-ELcEA=POPvqB?b=XH~!%+57TD<3>$m#iV{~BjV{$uADjL@1z8WBLBJM(fH9swkMU5gBnC6%7yOq4setValue(null, null); } - public function testDefaultConnectionReturnsPdoDriver(): void + public function testDefaultConnectionReturnsPdoConnection(): void { - $this->assertInstanceOf(PdoDriver::class, Database::instance()); + $this->assertInstanceOf(PdoConnection::class, Database::instance()); } public function testInstanceIsCachedPerConnectionName(): void @@ -38,8 +38,8 @@ class DatabaseTest extends TestCase public function testDifferentConnectionNamesAreDistinctInstances(): void { Config::set('db', [ - 'default' => ['driver' => 'pdo', 'connection' => ['host' => 'db', 'dbname' => 'a']], - 'second' => ['driver' => 'pdo', 'connection' => ['host' => 'db', 'dbname' => 'b']], + 'default' => ['host' => 'db', 'dbname' => 'a'], + 'second' => ['host' => 'db', 'dbname' => 'b'], ]); $this->assertNotSame(Database::instance('default'), Database::instance('second')); @@ -50,12 +50,4 @@ class DatabaseTest extends TestCase $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/PdoConnectionTest.php b/tests/Unit/PdoConnectionTest.php new file mode 100644 index 0000000..636e5f2 --- /dev/null +++ b/tests/Unit/PdoConnectionTest.php @@ -0,0 +1,164 @@ +connection = new PdoConnection($config); + + try { + $this->connection->pdo(); + } catch (MyException $e) { + $this->markTestSkipped('Нет живого подключения к MariaDB (заполните App/config/config.local.php): ' . $e->getMessage()); + } + + $this->connection->exec('DROP TABLE IF EXISTS pdo_connection_test'); + $this->connection->exec('CREATE TABLE pdo_connection_test (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255))'); + } + + protected function tearDown(): void + { + Profiler::reset(); + + if (isset($this->connection)) { + try { + $this->connection->exec('DROP TABLE IF EXISTS pdo_connection_test'); + } catch (MyException) { + // соединения не было — нечего дропать + } + } + } + + public function testFailoverThrowsWithAllHostErrors(): void + { + // Чисто логика failover — не требует живой БД, оба хоста заведомо недоступны + $connection = new PdoConnection(['hosts' => ['bad-host-1', 'bad-host-2'], 'dbname' => 'x', 'user' => 'x', 'password' => 'x']); + + try { + $connection->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->connection->query('INSERT INTO pdo_connection_test (name) VALUES (?)', ['foo']); + $row = $this->connection->query('SELECT * FROM pdo_connection_test WHERE name = ?', ['foo'])->fetch(); + + $this->assertSame('foo', $row['name']); + } + + public function testLastInsertId(): void + { + $this->connection->query('INSERT INTO pdo_connection_test (name) VALUES (?)', ['foo']); + $this->assertNotFalse($this->connection->lastInsertId()); + } + + public function testCommitPersistsChanges(): void + { + $this->connection->beginTransaction(); + $this->connection->query('INSERT INTO pdo_connection_test (name) VALUES (?)', ['committed']); + $this->connection->commit(); + + $row = $this->connection->query('SELECT * FROM pdo_connection_test WHERE name = ?', ['committed'])->fetch(); + $this->assertNotFalse($row); + } + + public function testRollbackDiscardsChanges(): void + { + $this->connection->beginTransaction(); + $this->connection->query('INSERT INTO pdo_connection_test (name) VALUES (?)', ['rolled_back']); + $this->connection->rollback(); + + $row = $this->connection->query('SELECT * FROM pdo_connection_test WHERE name = ?', ['rolled_back'])->fetch(); + $this->assertFalse($row); + } + + public function testNestedTransactionRollbackDoesNotAffectOuter(): void + { + $this->connection->beginTransaction(); + $this->connection->query('INSERT INTO pdo_connection_test (name) VALUES (?)', ['outer']); + + $this->connection->beginTransaction(); // SAVEPOINT sp_1 + $this->connection->query('INSERT INTO pdo_connection_test (name) VALUES (?)', ['inner']); + $this->connection->rollback(); // ROLLBACK TO SAVEPOINT sp_1 — не трогает 'outer' + + $this->connection->commit(); // фиксирует внешнюю транзакцию + + $outer = $this->connection->query('SELECT * FROM pdo_connection_test WHERE name = ?', ['outer'])->fetch(); + $inner = $this->connection->query('SELECT * FROM pdo_connection_test WHERE name = ?', ['inner'])->fetch(); + + $this->assertNotFalse($outer); + $this->assertFalse($inner); + } + + public function testTransactionHelperRollsBackOnException(): void + { + try { + $this->connection->transaction(function (PdoConnection $connection) { + $connection->query('INSERT INTO pdo_connection_test (name) VALUES (?)', ['should_rollback']); + throw new \RuntimeException('boom'); + }); + $this->fail('Ожидалось RuntimeException'); + } catch (\RuntimeException) { + // ожидаемо + } + + $row = $this->connection->query('SELECT * FROM pdo_connection_test WHERE name = ?', ['should_rollback'])->fetch(); + $this->assertFalse($row); + } + + public function testManualPrepareExecuteIsProfiledToo(): void + { + // Регрессия: профилирование должно ловить любой execute(), а не только PdoConnection::query() — + // Repository сам делает $this->pdo->prepare()->execute() напрямую (см. get()/create()/...). + Profiler::reset(); + + $stmt = $this->connection->prepare('INSERT INTO pdo_connection_test (name) VALUES (?)'); + $stmt->execute(['foo']); + + $this->assertSame(1, Profiler::count()); + } + + public function testInTransaction(): void + { + $this->assertFalse($this->connection->inTransaction()); + $this->connection->beginTransaction(); + $this->assertTrue($this->connection->inTransaction()); + $this->connection->commit(); + $this->assertFalse($this->connection->inTransaction()); + } + + public function testSqliteDialectConnectsAndQueries(): void + { + // dialect — не требует живой MariaDB (в отличие от остальных тестов класса, см. setUp()), + // регрессия на DSN/подключение для диалекта, отличного от дефолтного mysql + $connection = new PdoConnection(['dialect' => 'sqlite', 'dbname' => ':memory:']); + $connection->exec('CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)'); + $connection->query('INSERT INTO t (name) VALUES (?)', ['hello']); + + $row = $connection->query('SELECT * FROM t')->fetch(); + $this->assertSame('hello', $row['name']); + + $connection->beginTransaction(); + $connection->query('INSERT INTO t (name) VALUES (?)', ['rolled_back']); + $connection->rollback(); + + $count = $connection->query('SELECT COUNT(*) AS c FROM t')->fetch(); + $this->assertSame(1, (int) $count['c']); + } +} diff --git a/tests/Unit/PdoDriverTest.php b/tests/Unit/PdoDriverTest.php deleted file mode 100644 index 6c60312..0000000 --- a/tests/Unit/PdoDriverTest.php +++ /dev/null @@ -1,145 +0,0 @@ -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 testManualPrepareExecuteIsProfiledToo(): void - { - // Регрессия: профилирование должно ловить любой execute(), а не только PdoDriver::query() — - // Repository сам делает $this->pdo->prepare()->execute() напрямую (см. get()/create()/...). - Profiler::reset(); - - $stmt = $this->driver->prepare('INSERT INTO pdo_driver_test (name) VALUES (?)'); - $stmt->execute(['foo']); - - $this->assertSame(1, Profiler::count()); - } - - public function testInTransaction(): void - { - $this->assertFalse($this->driver->inTransaction()); - $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 index 425607b..c197368 100644 --- a/tests/Unit/ProfilerTest.php +++ b/tests/Unit/ProfilerTest.php @@ -9,7 +9,7 @@ class ProfilerTest extends TestCase { protected function setUp(): void { - // Другие тесты (PdoDriverTest/StatementTest/RepositoryTest) пишут в этот же + // Другие тесты (PdoConnectionTest/StatementTest/RepositoryTest) пишут в этот же // статический реестр, если есть живое подключение к БД — сбрасываем перед своими тестами. Profiler::reset(); } diff --git a/tests/Unit/RepositoryTest.php b/tests/Unit/RepositoryTest.php index beafb37..b08f1c1 100644 --- a/tests/Unit/RepositoryTest.php +++ b/tests/Unit/RepositoryTest.php @@ -6,7 +6,7 @@ use PDO; use PHPUnit\Framework\TestCase; use ReflectionClass; use Services\Database; -use Services\DataBase\Classes\PdoDriver; +use Services\DataBase\Classes\PdoConnection; use Services\DataBase\Classes\Profiler; use System\Classes\Config; use System\Classes\MyException; @@ -22,22 +22,22 @@ class RepositoryTestItemRepository extends Repository class RepositoryTest extends TestCase { - private PdoDriver $driver; + private PdoConnection $connection; private RepositoryTestItemRepository $repository; protected function setUp(): void { - $config = Config::get('db', 'default')['connection'] ?? []; - $this->driver = new PdoDriver($config); + $config = Config::get('db', 'default') ?? []; + $this->connection = new PdoConnection($config); try { - $this->driver->pdo(); + $this->connection->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->connection->exec('DROP TABLE IF EXISTS repository_test_items'); + $this->connection->exec('CREATE TABLE repository_test_items (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255), qty INT)'); $this->repository = new RepositoryTestItemRepository(); } @@ -46,9 +46,9 @@ class RepositoryTest extends TestCase { Profiler::reset(); - if (isset($this->driver)) { + if (isset($this->connection)) { try { - $this->driver->exec('DROP TABLE IF EXISTS repository_test_items'); + $this->connection->exec('DROP TABLE IF EXISTS repository_test_items'); } catch (MyException) { } } diff --git a/tests/Unit/StatementTest.php b/tests/Unit/StatementTest.php index 5fd6717..8fb0f05 100644 --- a/tests/Unit/StatementTest.php +++ b/tests/Unit/StatementTest.php @@ -3,22 +3,22 @@ namespace Tests\Unit; use PHPUnit\Framework\TestCase; -use Services\DataBase\Classes\PdoDriver; +use Services\DataBase\Classes\PdoConnection; use Services\DataBase\Classes\Profiler; use System\Classes\Config; use System\Classes\MyException; class StatementTest extends TestCase { - private PdoDriver $driver; + private PdoConnection $connection; protected function setUp(): void { - $config = Config::get('db', 'default')['connection'] ?? []; - $this->driver = new PdoDriver($config); + $config = Config::get('db', 'default') ?? []; + $this->connection = new PdoConnection($config); try { - $this->driver->pdo(); + $this->connection->pdo(); } catch (MyException $e) { $this->markTestSkipped('Нет живого подключения к MariaDB (заполните App/config/config.local.php): ' . $e->getMessage()); } @@ -31,21 +31,21 @@ class StatementTest extends TestCase public function testShowQuerySubstitutesPositionalParams(): void { - $statement = $this->driver->query('SELECT ? AS a, ? AS b', ['foo', 42]); + $statement = $this->connection->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']); + $statement = $this->connection->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']); + $statement = $this->connection->query('SELECT ? AS a', ['foo']); $this->assertSame($statement->showQuery(), $statement->sq()); }