dev
This commit is contained in:
parent
29c98a2a7a
commit
69ce89a695
@ -23,12 +23,13 @@ render()-методы Controller, CSS/JS-подключение во View). Эт
|
|||||||
1. Когда пользователь присылает референс-код — сначала спросить себя (не обязательно вслух), реально
|
1. Когда пользователь присылает референс-код — сначала спросить себя (не обязательно вслух), реально
|
||||||
ли это то, что нужно 1-в-1, или в нём могут быть баги/устаревшие паттерны конкретно под старый проект
|
ли это то, что нужно 1-в-1, или в нём могут быть баги/устаревшие паттерны конкретно под старый проект
|
||||||
(например поддержка нескольких "модулей", которых в Bicycle нет).
|
(например поддержка нескольких "модулей", которых в Bicycle нет).
|
||||||
2. Портировать с адаптацией под реалии Bicycle (уже собранные классы вроде `PdoDriver` с его SAVEPOINT-
|
2. Портировать с адаптацией под реалии Bicycle (уже собранные классы вроде `PdoConnection` с его SAVEPOINT-
|
||||||
логикой могут делать что-то лучше/надёжнее, чем то, что в референсе — не дублировать хуже).
|
логикой могут делать что-то лучше/надёжнее, чем то, что в референсе — не дублировать хуже).
|
||||||
3. Если сомневаешься, действительно ли имя/сигнатура должны совпадать буквально — можно посмотреть
|
3. Если сомневаешься, действительно ли имя/сигнатура должны совпадать буквально — можно посмотреть
|
||||||
исходник самого референс-проекта на диске (он часто лежит рядом, в других папках `/home/isaevea/http/*`),
|
исходник самого референс-проекта на диске (он часто лежит рядом, в других папках `/home/isaevea/http/*`),
|
||||||
а не гадать по вставленным фрагментам — так нашлись оригиналы `Database.php`/`PdoDriver.php`/
|
а не гадать по вставленным фрагментам — так нашлись оригиналы `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` (даже с ошибкой в другом месте кода) —
|
4. Если пользователь явно пишет что-то вроде `$this->pdo` (даже с ошибкой в другом месте кода) —
|
||||||
это сильный сигнал именно про нейминг, не опечатка; после второго повтора — не переспрашивать,
|
это сильный сигнал именно про нейминг, не опечатка; после второго повтора — не переспрашивать,
|
||||||
а сразу переименовывать под него (см. также [[feedback-less-confirmation]]).
|
а сразу переименовывать под него (см. также [[feedback-less-confirmation]]).
|
||||||
|
|||||||
@ -10,7 +10,7 @@ metadata:
|
|||||||
за одной абстракцией). Подробности использования — CLAUDE.md → разделы DataBase/Elasticsearch/Mongo.
|
за одной абстракцией). Подробности использования — CLAUDE.md → разделы DataBase/Elasticsearch/Mongo.
|
||||||
|
|
||||||
- **Реляционная БД (MariaDB, полностью рабочая и протестированная, реальное подключение)** —
|
- **Реляционная БД (MariaDB, полностью рабочая и протестированная, реальное подключение)** —
|
||||||
`Services\Database` (фабрика), `Services\DataBase\Classes\PdoDriver` (failover по хостам, транзакции
|
`Services\Database` (фабрика), `Services\DataBase\Classes\PdoConnection` (failover по хостам, транзакции
|
||||||
с вложенными SAVEPOINT — единственный источник истины по вложенности, общий на всё подключение),
|
с вложенными SAVEPOINT — единственный источник истины по вложенности, общий на всё подключение),
|
||||||
`Services\DataBase\Classes\Statement` (`showQuery()`/`sq()`, `execute()` профилирует в `Profiler`), `Services\DataBase\Classes\Profiler`
|
`Services\DataBase\Classes\Statement` (`showQuery()`/`sq()`, `execute()` профилирует в `Profiler`), `Services\DataBase\Classes\Profiler`
|
||||||
(тайминги, см. также `System\Classes\ProfilerToolbar`).
|
(тайминги, см. также `System\Classes\ProfilerToolbar`).
|
||||||
@ -20,7 +20,7 @@ metadata:
|
|||||||
1. `getList()` по умолчанию `PDO::FETCH_CLASS` с проброшенным `$this->obj_class` (в оригинале —
|
1. `getList()` по умолчанию `PDO::FETCH_CLASS` с проброшенным `$this->obj_class` (в оригинале —
|
||||||
`PDO::FETCH_KEY_PAIR` по умолчанию, и `FETCH_CLASS` не получает `obj_class` вовсе — нестыковка
|
`PDO::FETCH_KEY_PAIR` по умолчанию, и `FETCH_CLASS` не получает `obj_class` вовсе — нестыковка
|
||||||
PHPDoc/кода в оригинале, не повторяли).
|
PHPDoc/кода в оригинале, не повторяли).
|
||||||
2. Транзакции (`beginTransaction/commit/rollBack`) — тонкие делегаты в `PdoDriver` (см. выше), а не свой
|
2. Транзакции (`beginTransaction/commit/rollBack`) — тонкие делегаты в `PdoConnection` (см. выше), а не свой
|
||||||
счётчик вложенности в каждом `Repository`, как в оригинале (там это ломается, если два репозитория
|
счётчик вложенности в каждом `Repository`, как в оригинале (там это ломается, если два репозитория
|
||||||
на одном соединении оба вызывают `beginTransaction()` — оба думают, что «внешние»).
|
на одном соединении оба вызывают `beginTransaction()` — оба думают, что «внешние»).
|
||||||
Методы: `get()/getItemWhere()/getList()/create()/update()/delete()/deleteWhere()`, `primary_col`/`is_auto_increment`
|
Методы: `get()/getItemWhere()/getList()/create()/update()/delete()/deleteWhere()`, `primary_col`/`is_auto_increment`
|
||||||
@ -44,8 +44,8 @@ metadata:
|
|||||||
- **`System\Classes\ProfilerToolbar`** — debug-панель внизу страницы (аналог ProfilerToolbar для Kohana),
|
- **`System\Classes\ProfilerToolbar`** — debug-панель внизу страницы (аналог ProfilerToolbar для Kohana),
|
||||||
время/память/SQL из `Profiler`, видна только `role === 'admin'`, подключена в `App/view/layout.html`.
|
время/память/SQL из `Profiler`, видна только `role === 'admin'`, подключена в `App/view/layout.html`.
|
||||||
|
|
||||||
**Профилирование — в `Statement::execute()`, не в `PdoDriver::query()` (важно, был реальный баг):**
|
**Профилирование — в `Statement::execute()`, не в `PdoConnection::query()` (важно, был реальный баг):**
|
||||||
первая версия вешала таймер только на `PdoDriver::query()` через отдельный класс `ProfilerPDO::wrap()`
|
первая версия вешала таймер только на `PdoConnection::query()` через отдельный класс `ProfilerPDO::wrap()`
|
||||||
(теперь удалён) — но `Repository::get()/getList()/create()/update()/delete()/deleteWhere()` сами делают
|
(теперь удалён) — но `Repository::get()/getList()/create()/update()/delete()/deleteWhere()` сами делают
|
||||||
`$this->pdo->prepare($sql); $stmt->execute();` напрямую, в обход `query()` — то есть почти все реальные
|
`$this->pdo->prepare($sql); $stmt->execute();` напрямую, в обход `query()` — то есть почти все реальные
|
||||||
запросы через `Repository` не логировались вообще (пользователь поймал это по `ProfilerToolbar`,
|
запросы через `Repository` не логировались вообще (пользователь поймал это по `ProfilerToolbar`,
|
||||||
@ -53,13 +53,13 @@ metadata:
|
|||||||
что через `Statement` проходит вообще любое выполнение запроса (и `query()`, и ручной `prepare()+execute()`),
|
что через `Statement` проходит вообще любое выполнение запроса (и `query()`, и ручной `prepare()+execute()`),
|
||||||
таймер и вызов `Profiler::log()` теперь в `Statement::execute()` — единственной точке, которую нельзя
|
таймер и вызов `Profiler::log()` теперь в `Statement::execute()` — единственной точке, которую нельзя
|
||||||
обойти. Общий урок: если добавляешь профилирование/логирование поверх PDO-обёртки — вешать его на
|
обойти. Общий урок: если добавляешь профилирование/логирование поверх PDO-обёртки — вешать его на
|
||||||
низкоуровневый общий метод (`Statement`/`PDOStatement`), а не на удобный высокоуровневый (`PdoDriver::query()`),
|
низкоуровневый общий метод (`Statement`/`PDOStatement`), а не на удобный высокоуровневый (`PdoConnection::query()`),
|
||||||
если у обёртки есть другие пути выполнения запроса в обход этого высокоуровневого метода.
|
если у обёртки есть другие пути выполнения запроса в обход этого высокоуровневого метода.
|
||||||
|
|
||||||
**Свойство `Repository`/`PdoDriver` называется `$pdo`** (не `$_driver`/`$_pdo` с подчёркиванием, хотя это
|
**Свойство `Repository`/`PdoConnection` называется `$pdo`** (не `$_driver`/`$_pdo` с подчёркиванием, хотя это
|
||||||
нарушает общий стиль `_prefixed` protected-свойств проекта) — пользователь явно попросил именно так,
|
нарушает общий стиль `_prefixed` protected-свойств проекта) — пользователь явно попросил именно так,
|
||||||
дважды сам написал `$this->pdo` в своём коде раньше, чем я успел объяснить структуру. См. также
|
дважды сам написал `$this->pdo` в своём коде раньше, чем я успел объяснить структуру. См. также
|
||||||
`PdoDriver::rollback()` — с маленькой буквы (не `rollBack()`, хотя нативный `\PDO::rollBack()` — с
|
`PdoConnection::rollback()` — с маленькой буквы (не `rollBack()`, хотя нативный `\PDO::rollBack()` — с
|
||||||
большой); `Repository::rollBack()` (с большой буквы) — публичный метод, который делегирует в
|
большой); `Repository::rollBack()` (с большой буквы) — публичный метод, который делегирует в
|
||||||
`$this->pdo->rollback()` (с маленькой) — это НЕ опечатка, два разных метода двух разных классов.
|
`$this->pdo->rollback()` (с маленькой) — это НЕ опечатка, два разных метода двух разных классов.
|
||||||
|
|
||||||
@ -74,7 +74,7 @@ metadata:
|
|||||||
(внешний хост, не контейнер `db` из docker-dev) — эта часть инфраструктуры уже готова пользователем,
|
(внешний хост, не контейнер `db` из docker-dev) — эта часть инфраструктуры уже готова пользователем,
|
||||||
тесты по MariaDB реально гоняются, не skip.
|
тесты по MariaDB реально гоняются, не skip.
|
||||||
|
|
||||||
**Тесты**: `PdoDriverTest`/`StatementTest`/`RepositoryTest` бьют по реальной MariaDB (не мокают) — гейт
|
**Тесты**: `PdoConnectionTest`/`StatementTest`/`RepositoryTest` бьют по реальной MariaDB (не мокают) — гейт
|
||||||
через `markTestSkipped()` в `setUp()` при неудачном подключении остался в коде на случай, если у
|
через `markTestSkipped()` в `setUp()` при неудачном подключении остался в коде на случай, если у
|
||||||
кого-то ещё не настроен `config.local.php`, но сейчас реально проходят (не skip). `ElasticsearchClientTest` —
|
кого-то ещё не настроен `config.local.php`, но сейчас реально проходят (не skip). `ElasticsearchClientTest` —
|
||||||
через `Client::ping()`. `MongoDriverTest` — через `extension_loaded('mongodb')`. Эти два оживут сами,
|
через `Client::ping()`. `MongoDriverTest` — через `extension_loaded('mongodb')`. Эти два оживут сами,
|
||||||
|
|||||||
168
.claude/memory/Общий_промт_по_конструктору_метрик.md
Normal file
168
.claude/memory/Общий_промт_по_конструктору_метрик.md
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
Спроектируй и реализуй универсальный конструктор пользовательских метрик для аналитического дашборда.
|
||||||
|
|
||||||
|
Цель
|
||||||
|
|
||||||
|
Пользователь без знания программирования должен иметь возможность собрать собственную метрику из доступных данных, настроить способ расчёта и выбрать её визуальное представление.
|
||||||
|
|
||||||
|
Конструктор должен подходить как для простых показателей, так и для сложных вычисляемых метрик.
|
||||||
|
|
||||||
|
Основной сценарий
|
||||||
|
|
||||||
|
Раздели создание метрики на три логических этапа:
|
||||||
|
|
||||||
|
1. Основная информация.
|
||||||
|
2. Расчёт.
|
||||||
|
3. Отображение.
|
||||||
|
|
||||||
|
Пользователь должен свободно перемещаться между этапами, не теряя введённые данные.
|
||||||
|
|
||||||
|
Основная информация
|
||||||
|
|
||||||
|
Предусмотри:
|
||||||
|
|
||||||
|
- название метрики;
|
||||||
|
- необязательное описание;
|
||||||
|
- категорию или смысловую группу;
|
||||||
|
- единицу измерения;
|
||||||
|
- понятное отличие новой метрики от редактирования существующей.
|
||||||
|
|
||||||
|
Расчёт
|
||||||
|
|
||||||
|
Поддержи несколько способов создания метрики:
|
||||||
|
|
||||||
|
- выбор готового показателя;
|
||||||
|
- сумма выбранных значений;
|
||||||
|
- разница между показателями;
|
||||||
|
- отношение или процент;
|
||||||
|
- среднее, минимум и максимум;
|
||||||
|
- накопительный итог;
|
||||||
|
- собственная формула.
|
||||||
|
|
||||||
|
Источники данных должны определяться динамически. Не используй жёстко заданные категории или показатели.
|
||||||
|
|
||||||
|
Позволь выбирать:
|
||||||
|
|
||||||
|
- набор учитываемых данных;
|
||||||
|
- временной период;
|
||||||
|
- режим расчёта;
|
||||||
|
- фильтры;
|
||||||
|
- группировку;
|
||||||
|
- правила обработки отсутствующих значений.
|
||||||
|
|
||||||
|
Формулы
|
||||||
|
|
||||||
|
Добавь редактор собственных формул с поддержкой:
|
||||||
|
|
||||||
|
- арифметических операций;
|
||||||
|
- скобок;
|
||||||
|
- доступных показателей и категорий;
|
||||||
|
- ссылок на другие пользовательские метрики;
|
||||||
|
- функций агрегации;
|
||||||
|
- сравнений с предыдущими периодами;
|
||||||
|
- автодополнения;
|
||||||
|
- описания доступных переменных;
|
||||||
|
- проверки формулы до сохранения.
|
||||||
|
|
||||||
|
Конструктор должен обнаруживать:
|
||||||
|
|
||||||
|
- неизвестные переменные;
|
||||||
|
- синтаксические ошибки;
|
||||||
|
- деление на ноль;
|
||||||
|
- циклические зависимости между метриками;
|
||||||
|
- несовместимые единицы измерения;
|
||||||
|
- отсутствующие источники данных.
|
||||||
|
|
||||||
|
Ошибки показывай рядом с проблемным полем и объясняй понятным языком.
|
||||||
|
|
||||||
|
Отображение
|
||||||
|
|
||||||
|
Позволь настроить:
|
||||||
|
|
||||||
|
- формат числа;
|
||||||
|
- количество знаков после запятой;
|
||||||
|
- денежную единицу или процент;
|
||||||
|
- направление оценки результата;
|
||||||
|
- целевое значение;
|
||||||
|
- предупредительные и критические пороги;
|
||||||
|
- цветовые состояния;
|
||||||
|
- выбранные графики;
|
||||||
|
- отображение на дашборде.
|
||||||
|
|
||||||
|
Поддержи основные варианты визуализации:
|
||||||
|
|
||||||
|
- числовая карточка;
|
||||||
|
- прогресс;
|
||||||
|
- сравнение;
|
||||||
|
- динамика;
|
||||||
|
- столбчатый график;
|
||||||
|
- линейный график;
|
||||||
|
- компактный индикатор состояния.
|
||||||
|
|
||||||
|
Показывай живое превью метрики. Оно должно обновляться при изменении настроек, но не сохранять черновик как готовую метрику.
|
||||||
|
|
||||||
|
Пользовательский опыт
|
||||||
|
|
||||||
|
Интерфейс должен постепенно раскрывать сложность:
|
||||||
|
|
||||||
|
- сначала показывать только обязательные параметры;
|
||||||
|
- дополнительные настройки размещать в раскрывающихся блоках;
|
||||||
|
- скрывать поля, которые не относятся к выбранному типу расчёта;
|
||||||
|
- сохранять незавершённый черновик;
|
||||||
|
- предупреждать при закрытии формы с несохранёнными изменениями;
|
||||||
|
- не перегружать экран вспомогательным текстом.
|
||||||
|
|
||||||
|
Предусмотри понятные состояния:
|
||||||
|
|
||||||
|
- пустые данные;
|
||||||
|
- загрузка;
|
||||||
|
- ошибка;
|
||||||
|
- некорректная формула;
|
||||||
|
- успешное сохранение;
|
||||||
|
- редактирование;
|
||||||
|
- дублирование;
|
||||||
|
- удаление и восстановление.
|
||||||
|
|
||||||
|
Управление метриками
|
||||||
|
|
||||||
|
Пользователь должен иметь возможность:
|
||||||
|
|
||||||
|
- создавать метрики;
|
||||||
|
- редактировать;
|
||||||
|
- дублировать;
|
||||||
|
- временно скрывать;
|
||||||
|
- закреплять на дашборде;
|
||||||
|
- менять порядок;
|
||||||
|
- переносить в хранилище;
|
||||||
|
- удалять с возможностью восстановления;
|
||||||
|
- окончательно удалять;
|
||||||
|
- импортировать и экспортировать конфигурации.
|
||||||
|
|
||||||
|
Совместимость
|
||||||
|
|
||||||
|
Модель метрики должна быть версионируемой. При развитии конструктора старые сохранённые метрики должны продолжать работать или автоматически мигрировать в новую схему.
|
||||||
|
|
||||||
|
Изменение или удаление источника данных не должно незаметно ломать метрику. Показывай её состояние и предлагай выбрать замену.
|
||||||
|
|
||||||
|
Адаптивность и доступность
|
||||||
|
|
||||||
|
Конструктор должен одинаково хорошо работать на компьютерах и мобильных устройствах:
|
||||||
|
|
||||||
|
- без горизонтального скролла;
|
||||||
|
- с удобными зонами нажатия;
|
||||||
|
- с поддержкой экранной клавиатуры;
|
||||||
|
- с полной клавиатурной навигацией;
|
||||||
|
- с корректными focus-состояниями;
|
||||||
|
- с доступными названиями элементов;
|
||||||
|
- с достаточным контрастом.
|
||||||
|
|
||||||
|
Критерии готовности
|
||||||
|
|
||||||
|
- Простую метрику можно создать за несколько понятных действий.
|
||||||
|
- Сложную формулу можно собрать и проверить до сохранения.
|
||||||
|
- Все параметры влияют на живое превью.
|
||||||
|
- Некорректную конфигурацию невозможно сохранить.
|
||||||
|
- Ошибки объясняют причину и способ исправления.
|
||||||
|
- Старые метрики остаются совместимыми.
|
||||||
|
- Интерфейс корректно работает на мобильных и настольных экранах.
|
||||||
|
- Нет жёсткой зависимости от конкретных категорий, показателей или структуры данных.
|
||||||
|
- Основные пользовательские сценарии покрыты автоматическими тестами.
|
||||||
@ -20,9 +20,96 @@ metadata:
|
|||||||
|
|
||||||
`id`, `login`, `password`, `name`, `email`, `date_add`, `last_login`. Пока достаточно.
|
`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 (счета)
|
## Accounts (счета)
|
||||||
|
|
||||||
Три типа `type_acc`:
|
**Один пользователь приложения может вести несколько реальных счетов, включая счета, номинально
|
||||||
|
оформленные на других членов семьи** (пример: «мой счёт», «счёт жены») — это не мультипользовательская
|
||||||
|
функция и не требует отдельного `user_id` на счёт: все они просто разные строки `accounts` с разными
|
||||||
|
`title`, привязанные к одному `user_id` (см. "Продукт" выше). Различать их — по `title`, не по схеме.
|
||||||
|
|
||||||
|
**Будущие типы счёта под инвестиции (не сейчас, см. "Приоритет фич" → Инвестиции)** — уточнено, это
|
||||||
|
два разных счёта, не один:
|
||||||
|
- **Брокерский** — акции, облигации, фонды, фьючерсы.
|
||||||
|
- **ЦФА** (цифровые финансовые активы) — долговые, ноты, картины (NFT-подобное), крипто.
|
||||||
|
|
||||||
|
Оба не подходят ни под `cash` (не наличные), ни под `bank` (не просто расчётный счёт), ни под `savings`
|
||||||
|
(не вклад под процент, а портфель с рыночной переоценкой, не фиксированной ставкой). **Как именно с ними
|
||||||
|
работать (отдельные позиции/тикеры внутри счёта, переоценка, дивиденды/купоны, transfer при
|
||||||
|
покупке/продаже) — пользователь явно попросил отложить обсуждение, не проектировать сейчас вообще**,
|
||||||
|
даже на уровне схемы `accounts`/`type_acc`. Сейчас — только основной бюджет (cash/bank/savings).
|
||||||
|
|
||||||
|
Три типа `type_acc` (актуальные, реализовано):
|
||||||
- **`cash`** — кошелёк (наличные).
|
- **`cash`** — кошелёк (наличные).
|
||||||
- **`bank`** — счёт в банке; карта просто привязана к нему, сама по себе денег не хранит (это
|
- **`bank`** — счёт в банке; карта просто привязана к нему, сама по себе денег не хранит (это
|
||||||
сознательное упрощение — раньше в модели были отдельные "дебетовая карта"/"кредитная карта"/
|
сознательное упрощение — раньше в модели были отдельные "дебетовая карта"/"кредитная карта"/
|
||||||
@ -31,50 +118,234 @@ metadata:
|
|||||||
привязанная к `account_id`). Опционально может иметь цель (см. `goals` ниже) — если записи в `goals`
|
привязанная к `account_id`). Опционально может иметь цель (см. `goals` ниже) — если записи в `goals`
|
||||||
нет, это просто копилка "без цели", копишь под процент без конкретной суммы/даты.
|
нет, это просто копилка "без цели", копишь под процент без конкретной суммы/даты.
|
||||||
|
|
||||||
Поля: `id`, `user_id`, `type_acc`, `title`, `summa` (баланс), `is_delete` (архивация вместо удаления),
|
Поля: `id`, `user_id`, `type_acc`, `title`, `summa` (баланс, вводится как есть при создании счёта —
|
||||||
`order`.
|
любая сумма и своя валюта, не обязательно с нуля), `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 (статьи) — с подкатегориями
|
## Categories (статьи) — с подкатегориями
|
||||||
|
|
||||||
`id`, `user_id`, `parent_id` (null — категория верхнего уровня; иначе — id родителя), `title`, `type`
|
`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
|
## Transactions
|
||||||
|
|
||||||
`id`, `user_id`, `categorie_id`, `account_f_id` (откуда), `account_in_id` (куда), `date`, `summa`,
|
`id`, `user_id`, `categorie_id`, `account_f_id` (откуда), `account_in_id` (куда), `date`, `summa`,
|
||||||
`type` (`income`/`expense`/`transfer`). Для дохода — только `account_in_id`, для расхода — только
|
`type` (`income`/`expense`/`transfer`). Для дохода — только `account_in_id`, для расхода — только
|
||||||
`account_f_id`, для перевода — оба.
|
`account_f_id`, для перевода — оба.
|
||||||
|
|
||||||
**Обычный transfer между обычными счетами — без категории** (пример: переложил 3000 ₽ с карты в
|
**Все transfer — без категории, без исключений**, в том числе пополнение savings-счёта/копилки/цели:
|
||||||
кошелёк — деньги не потрачены и не заработаны, просто переехали, категория тут не имеет смысла).
|
переложил деньги в копилку «Отпуск» — это просто перемещение между своими счетами, не потрачено и не
|
||||||
|
заработано, `categorie_id = null`. **Решено** (закрывает предыдущий открытый вопрос про задвоение):
|
||||||
|
реальный расход считается только тогда, когда деньги действительно потрачены — гостиница, билеты и
|
||||||
|
т.п. оформляются обычными `expense`-транзакциями с `account_f_id` = счёт, с которого реально платили
|
||||||
|
(в т.ч. со счёта-копилки, если платили прямо с него), и категорией «Отпуск» (точнее — под-категорией
|
||||||
|
типа «Билеты»/«Гостиница»). Задвоения нет, потому что откладывание в копилку категорией не помечается
|
||||||
|
вовсе — план/факт по категории «Отпуск» видит только реальные траты.
|
||||||
|
|
||||||
**⚠️ ОТКРЫТЫЙ ВОПРОС — transfer на savings-счёт (пополнение копилки/цели) МОЖЕТ иметь категорию**,
|
**Баланс счёта (`accounts.summa`) — решено, «как в банках».** Не пересчитывать всю историю на каждый
|
||||||
и пользователь хочет, чтобы такой transfer засчитывался как "потрачено" по этой категории в
|
показ, но и не хранить как самостоятельное число без контроля. Гибрид: `summa` — хранимый кэш, который
|
||||||
план/факт бюджета — то есть отложил 10 000 ₽ в копилку «Отпуск» в январе → сразу считается
|
обновляется **атомарно, в той же БД-транзакции**, что и сама операция создания/правки/удаления
|
||||||
"потрачено 10 000 на Отпуск" в январском бюджете, ещё до самой поездки.
|
`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 (план/факт)
|
## Budgets (план/факт)
|
||||||
|
|
||||||
`id`, `categorie_id`, `plan_summ`, `year`, `month`, `type`. План — вручную на месяц по категории; факт
|
`id`, `categorie_id`, `plan_summ`, `year`, `month`, `type`. Факт — не хранится, считается на лету как
|
||||||
— не хранится, считается на лету как сумма `transactions` по этой категории за месяц (с учётом
|
сумма `transactions` по этой категории за месяц (с учётом открытого вопроса выше — возможно, включая
|
||||||
открытого вопроса выше — возможно, включая помеченные transfer на копилки).
|
помеченные 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 (история ставок)
|
## Account_rates (история ставок)
|
||||||
|
|
||||||
`id`, `account_id`, `rate_percent`, `valid_from`, `valid_to` (null = текущая). Общая механика для
|
`id`, `account_id`, `rate_percent`, `valid_from`, `valid_to` (null = текущая).
|
||||||
`savings`, с расчётом на будущее переиспользование для кредита (там % работает в другую сторону —
|
|
||||||
не начисление, а долг — но структура «ставка + период» та же). Кредит сейчас вне скоупа.
|
**История (не единственное поле на `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 (цель для копилки)
|
## Goals (цель для копилки)
|
||||||
|
|
||||||
@ -86,6 +357,14 @@ metadata:
|
|||||||
категорией «Проценты по вкладам/целям» (одна на все копилки, не по одной на каждую) — заводится
|
категорией «Проценты по вкладам/целям» (одна на все копилки, не по одной на каждую) — заводится
|
||||||
пользователю автоматически при первом использовании.
|
пользователю автоматически при первом использовании.
|
||||||
|
|
||||||
|
## Быстрый ввод операции — замена идее Telegram-бота
|
||||||
|
|
||||||
|
В старом `бюджет_проект_план.md` (заметки ДО сброса, источник идей) был пункт «Telegram-бот для
|
||||||
|
быстрого ввода трат». **Переиграно**: вместо внешнего бота — страница быстрого добавления операции
|
||||||
|
внутри самого Bicycle (тот же стек, без бот-токена/вебхука/парсинга свободного текста от Telegram API).
|
||||||
|
**Не решено** — закреплять ли её как PWA-иконку на экране телефона (`View::setManifest()` уже
|
||||||
|
поддержан в проекте, см. CLAUDE.md) — пользователь пока не уверен, обсудить отдельно перед реализацией.
|
||||||
|
|
||||||
## Приоритет фич
|
## Приоритет фич
|
||||||
|
|
||||||
1. **Цели (план/факт) + анализ бюджета** — сейчас.
|
1. **Цели (план/факт) + анализ бюджета** — сейчас.
|
||||||
|
|||||||
@ -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-файлов.
|
||||||
|
|
||||||
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -8,7 +8,8 @@ config.json
|
|||||||
App/config/config.local.php
|
App/config/config.local.php
|
||||||
App/config/auth_users.php
|
App/config/auth_users.php
|
||||||
|
|
||||||
/App/logs/
|
/App/logs/*
|
||||||
|
!/App/logs/.gitkeep
|
||||||
|
|
||||||
**/files/**/*
|
**/files/**/*
|
||||||
!**/files/**/
|
!**/files/**/
|
||||||
|
|||||||
36
App/Repositories/AccountRateRepository.php
Normal file
36
App/Repositories/AccountRateRepository.php
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description AccountRateRepository.php
|
||||||
|
* @copyright (c) 11/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Repositories;
|
||||||
|
|
||||||
|
use System\Classes\Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Репозиторий таблицы account_rates (история процентных ставок savings-счёта).
|
||||||
|
*/
|
||||||
|
class AccountRateRepository extends Repository
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
|
||||||
|
*/
|
||||||
|
public function __construct(?string $connection = null)
|
||||||
|
{
|
||||||
|
parent::__construct('account_rates', connection: $connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Текущая ставка счёта (запись с valid_to IS NULL — см. бюджет_текущий_план.md).
|
||||||
|
*
|
||||||
|
* @param int $account_id
|
||||||
|
* @return object|false|null Строка account_rates | false, если не найдено | null (не бывает — $where не пуст)
|
||||||
|
*/
|
||||||
|
public function getCurrentRate(int $account_id): mixed
|
||||||
|
{
|
||||||
|
return $this->getItemWhere("account_id = $account_id AND valid_to IS NULL");
|
||||||
|
}
|
||||||
|
}
|
||||||
25
App/Repositories/AccountRepository.php
Normal file
25
App/Repositories/AccountRepository.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description AccountRepository.php
|
||||||
|
* @copyright (c) 11/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Repositories;
|
||||||
|
|
||||||
|
use System\Classes\Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Репозиторий таблицы accounts (счета: cash/bank/savings).
|
||||||
|
*/
|
||||||
|
class AccountRepository extends Repository
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
|
||||||
|
*/
|
||||||
|
public function __construct(?string $connection = null)
|
||||||
|
{
|
||||||
|
parent::__construct('accounts', connection: $connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
App/Repositories/BudgetRepository.php
Normal file
25
App/Repositories/BudgetRepository.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description BudgetRepository.php
|
||||||
|
* @copyright (c) 11/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Repositories;
|
||||||
|
|
||||||
|
use System\Classes\Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Репозиторий таблицы budgets (план по категории на месяц; факт считается на лету из transactions).
|
||||||
|
*/
|
||||||
|
class BudgetRepository extends Repository
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
|
||||||
|
*/
|
||||||
|
public function __construct(?string $connection = null)
|
||||||
|
{
|
||||||
|
parent::__construct('budgets', connection: $connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
App/Repositories/CategoryRepository.php
Normal file
25
App/Repositories/CategoryRepository.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description CategoryRepository.php
|
||||||
|
* @copyright (c) 11/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Repositories;
|
||||||
|
|
||||||
|
use System\Classes\Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Репозиторий таблицы categories (статьи income/expense, с подкатегориями через parent_id).
|
||||||
|
*/
|
||||||
|
class CategoryRepository extends Repository
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
|
||||||
|
*/
|
||||||
|
public function __construct(?string $connection = null)
|
||||||
|
{
|
||||||
|
parent::__construct('categories', connection: $connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
App/Repositories/GoalRepository.php
Normal file
25
App/Repositories/GoalRepository.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description GoalRepository.php
|
||||||
|
* @copyright (c) 11/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Repositories;
|
||||||
|
|
||||||
|
use System\Classes\Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Репозиторий таблицы goals (цель для savings-счёта — необязательная надстройка).
|
||||||
|
*/
|
||||||
|
class GoalRepository extends Repository
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
|
||||||
|
*/
|
||||||
|
public function __construct(?string $connection = null)
|
||||||
|
{
|
||||||
|
parent::__construct('goals', connection: $connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
App/Repositories/TransactionRepository.php
Normal file
25
App/Repositories/TransactionRepository.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description TransactionRepository.php
|
||||||
|
* @copyright (c) 11/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Repositories;
|
||||||
|
|
||||||
|
use System\Classes\Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Репозиторий таблицы transactions (income/expense/transfer).
|
||||||
|
*/
|
||||||
|
class TransactionRepository extends Repository
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
|
||||||
|
*/
|
||||||
|
public function __construct(?string $connection = null)
|
||||||
|
{
|
||||||
|
parent::__construct('transactions', connection: $connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
App/Repositories/UserRepository.php
Normal file
25
App/Repositories/UserRepository.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description UserRepository.php
|
||||||
|
* @copyright (c) 11/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Repositories;
|
||||||
|
|
||||||
|
use System\Classes\Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Репозиторий таблицы users.
|
||||||
|
*/
|
||||||
|
class UserRepository extends Repository
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
|
||||||
|
*/
|
||||||
|
public function __construct(?string $connection = null)
|
||||||
|
{
|
||||||
|
parent::__construct('users', connection: $connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,14 +8,12 @@ return [
|
|||||||
|
|
||||||
'db' => [
|
'db' => [
|
||||||
'default' => [
|
'default' => [
|
||||||
'driver' => 'pdo',
|
'dialect' => 'mysql', // mysql|pgsql|sqlite — какой DSN собирает PdoConnection::buildDsn()
|
||||||
'connection' => [
|
'host' => '192.168.1.36',
|
||||||
'host' => '192.168.1.36',
|
'port' => 3307,
|
||||||
'port' => 3307,
|
'user' => 'root',
|
||||||
'user' => 'root',
|
'password' => 'qwerty',
|
||||||
'password' => 'qwerty',
|
'dbname' => 'budget'
|
||||||
'dbname' => 'budget'
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
0
App/logs/.gitkeep
Normal file
0
App/logs/.gitkeep
Normal file
0
App/media/css/.gitkeep
Normal file
0
App/media/css/.gitkeep
Normal file
0
App/media/js/.gitkeep
Normal file
0
App/media/js/.gitkeep
Normal file
@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
use System\Classes\CSRF;
|
use System\Classes\CSRF;
|
||||||
|
use System\Classes\ProfilerToolbar;
|
||||||
|
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@ -25,6 +26,7 @@ use System\Classes\CSRF;
|
|||||||
|
|
||||||
<?php if ($user !== null): ?>
|
<?php if ($user !== null): ?>
|
||||||
<p>Вы вошли как <strong><?= htmlspecialchars($user['login'], ENT_QUOTES, 'UTF-8') ?></strong>.</p>
|
<p>Вы вошли как <strong><?= htmlspecialchars($user['login'], ENT_QUOTES, 'UTF-8') ?></strong>.</p>
|
||||||
|
<?= ProfilerToolbar::render() ?>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?php if ($error !== null): ?>
|
<?php if ($error !== null): ?>
|
||||||
<div class="alert alert-danger"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
|
<div class="alert alert-danger"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
|
||||||
|
|||||||
50
CLAUDE.md
50
CLAUDE.md
@ -43,7 +43,7 @@ App/config/ — конфиги (config.php; config.local.
|
|||||||
App/view/ — шаблоны приложения (.html файлы с PHP-кодом)
|
App/view/ — шаблоны приложения (.html файлы с PHP-кодом)
|
||||||
App/media/ — статические ресурсы (js, css, img)
|
App/media/ — статические ресурсы (js, css, img)
|
||||||
Services/Auth/ — авторизация: интерфейс AuthDriver + FileAuthDriver (см. раздел Auth ниже)
|
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/Elasticsearch.php, Elasticsearch/ — поиск: фабрика + Client (REST, см. раздел Elasticsearch ниже)
|
||||||
Services/Mongo.php, Mongo/ — документная БД: фабрика + MongoDriver (см. раздел Mongo ниже)
|
Services/Mongo.php, Mongo/ — документная БД: фабрика + MongoDriver (см. раздел Mongo ниже)
|
||||||
Services/Mail/, PDF/ — пустые каталоги-заготовки под будущие сервисы (PHPMailer / dompdf); кода пока нет
|
Services/Mail/, PDF/ — пустые каталоги-заготовки под будущие сервисы (PHPMailer / dompdf); кода пока нет
|
||||||
@ -109,12 +109,12 @@ PhpStorm может показывать предупреждение «Namespac
|
|||||||
| `Services\Auth` | Точка входа в авторизацию: `instance(?string $driver = null)`, кэш по драйверу |
|
| `Services\Auth` | Точка входа в авторизацию: `instance(?string $driver = null)`, кэш по драйверу |
|
||||||
| `Services\Auth\AuthDriver` | Интерфейс: `login()`, `logout()`, `loggedIn()`, `getUser()`, `checkPassword()` |
|
| `Services\Auth\AuthDriver` | Интерфейс: `login()`, `logout()`, `loggedIn()`, `getUser()`, `checkPassword()` |
|
||||||
| `Services\Auth\FileAuthDriver` | Авторизация по файлу пользователей (по умолчанию); позже — БД/LDAP/Keycloak |
|
| `Services\Auth\FileAuthDriver` | Авторизация по файлу пользователей (по умолчанию); позже — БД/LDAP/Keycloak |
|
||||||
| `Services\Database` | Точка входа в реляционную БД (MariaDB): `instance(?string $name = null): PdoDriver`, кэш по имени подключения |
|
| `Services\Database` | Точка входа в реляционную БД (MariaDB): `instance(?string $name = null): PdoConnection`, кэш по имени подключения |
|
||||||
| `Services\DataBase\Classes\PdoDriver` | Обёртка над `PDO`: `query()`/`prepare()`/`exec()`, failover по `hosts`, транзакции с вложенными SAVEPOINT (`transaction()`) |
|
| `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\Statement` | `extends PDOStatement` (через `PDO::ATTR_STATEMENT_CLASS`); `showQuery()`/`sq()` — SQL с подставленными параметрами, для дебага; `execute()` также профилирует в `Profiler` |
|
||||||
| `Services\DataBase\Classes\Profiler` | Статический накопитель таймингов запросов: `log()`, `entries()`, `totalTime()`, `count()`, `reset()` |
|
| `Services\DataBase\Classes\Profiler` | Статический накопитель таймингов запросов: `log()`, `entries()`, `totalTime()`, `count()`, `reset()` |
|
||||||
| `Services\DataBase\Model` | `#[AllowDynamicProperties]`, лёгкий пассивный носитель данных строки с настоящими (не спрятанными) свойствами — под `PDO::FETCH_CLASS`; `toArray()` |
|
| `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` | Точка входа в 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\Elasticsearch\Client` | REST-обёртка на `HTTP\Client\Curl` (без composer-зависимости `elasticsearch/elasticsearch`): `index()/get()/search()/delete()/exists()/createIndex()/deleteIndex()/ping()` |
|
||||||
| `Services\Mongo` | Точка входа в MongoDB: `instance(?string $name = null): MongoDriver`, кэш по имени подключения |
|
| `Services\Mongo` | Точка входа в MongoDB: `instance(?string $name = null): MongoDriver`, кэш по имени подключения |
|
||||||
@ -311,34 +311,49 @@ return [
|
|||||||
### DataBase (Services\Database, System\Classes\Repository)
|
### DataBase (Services\Database, System\Classes\Repository)
|
||||||
|
|
||||||
Реляционная БД (MariaDB) через PDO. Конфиг — `Config::get('db', $name)`, ключ `$name` — это имя
|
Реляционная БД (MariaDB) через PDO. Конфиг — `Config::get('db', $name)`, ключ `$name` — это имя
|
||||||
*подключения* (не тип драйвера — драйвер сейчас всегда `'pdo'`), по умолчанию `'default'`:
|
*подключения*, по умолчанию `'default'`; значение — параметры подключения плоским массивом
|
||||||
|
(`host`/`hosts`/`port`/`user`/`password`/`dbname`/`dialect`/`charset`), без обёртки под тип
|
||||||
|
драйвера — PDO не «драйвер» в смысле выбора между несколькими реализациями (это сама обёртка над
|
||||||
|
клиентской библиотекой конкретной СУБД), других вариантов подключения к реляционной БД в проекте
|
||||||
|
нет, поэтому и нечего выбирать конфигом:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
use Services\Database;
|
use Services\Database;
|
||||||
|
|
||||||
$driver = Database::instance(); // Config::get('db', 'default')
|
$connection = Database::instance(); // Config::get('db', 'default')
|
||||||
$stmt = $driver->query('SELECT * FROM users WHERE id = ?', [42]);
|
$stmt = $connection->query('SELECT * FROM users WHERE id = ?', [42]);
|
||||||
$row = $stmt->fetch();
|
$row = $stmt->fetch();
|
||||||
|
|
||||||
echo $stmt->sq(); // SQL с подставленными параметрами — для дебага (Statement::showQuery())
|
echo $stmt->sq(); // SQL с подставленными параметрами — для дебага (Statement::showQuery())
|
||||||
```
|
```
|
||||||
|
|
||||||
Подключение — ленивое (первое обращение к `pdo()`/`query()`), с failover: `connection.hosts`
|
Подключение — ленивое (первое обращение к `pdo()`/`query()`), с failover: `hosts`
|
||||||
(массив) перебирается по порядку до первого успешного, иначе — `MyException` со списком ошибок
|
(массив) перебирается по порядку до первого успешного, иначе — `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()` считают уровень вложенности сами), либо через обёртку:
|
(`beginTransaction()`/`commit()`/`rollback()` считают уровень вложенности сами), либо через обёртку:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$driver->transaction(function ($driver) {
|
$connection->transaction(function ($connection) {
|
||||||
$driver->query('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, 1]);
|
$connection->query('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, 1]);
|
||||||
$driver->query('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, 2]);
|
$connection->query('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, 2]);
|
||||||
});
|
});
|
||||||
// исключение внутри колбэка → rollback (или ROLLBACK TO SAVEPOINT на вложенном уровне) + повторный throw
|
// исключение внутри колбэка → rollback (или ROLLBACK TO SAVEPOINT на вложенном уровне) + повторный throw
|
||||||
```
|
```
|
||||||
|
|
||||||
Тайминги запросов — `Services\DataBase\Classes\Profiler::entries()`/`totalTime()` (наполняется
|
Тайминги запросов — `Services\DataBase\Classes\Profiler::entries()`/`totalTime()` (наполняется
|
||||||
автоматически из `Statement::execute()` — единой точки для любого выполнения запроса, в т.ч.
|
автоматически из `Statement::execute()` — единой точки для любого выполнения запроса, в т.ч.
|
||||||
ручного `$driver->prepare()->execute()` в обход `query()`, как делает большинство методов `Repository`).
|
ручного `$connection->prepare()->execute()` в обход `query()`, как делает большинство методов `Repository`).
|
||||||
|
|
||||||
**Repository** — `System\Classes\Repository` (`@template T of object`, abstract CRUD), конкретные — в
|
**Repository** — `System\Classes\Repository` (`@template T of object`, abstract CRUD), конкретные — в
|
||||||
`App/Repositories/*`. Таблица и класс строки задаются через конструктор, не через переопределение
|
`App/Repositories/*`. Таблица и класс строки задаются через конструктор, не через переопределение
|
||||||
@ -366,7 +381,7 @@ $repo->create(['title' => 'Q3', 'amount' => 1000]); // lastInsertId
|
|||||||
$repo->update(['id' => 1, 'amount' => 1200]); // primary_col обязателен в $data
|
$repo->update(['id' => 1, 'amount' => 1200]); // primary_col обязателен в $data
|
||||||
$repo->delete(1);
|
$repo->delete(1);
|
||||||
$repo->deleteWhere('status = ?', ['closed']);
|
$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` — переопределяются в
|
`public string $primary_col = 'id'` и `public bool $is_auto_increment = true` — переопределяются в
|
||||||
@ -381,7 +396,7 @@ $repo->transaction(fn () => /* несколько операций одной т
|
|||||||
нестыковка между PHPDoc и кодом в оригинале). Транзакции по той же причине не портированы
|
нестыковка между PHPDoc и кодом в оригинале). Транзакции по той же причине не портированы
|
||||||
один-в-один: там свой счётчик вложенности в каждом `Repository`, что ломается при двух репозиториях
|
один-в-один: там свой счётчик вложенности в каждом `Repository`, что ломается при двух репозиториях
|
||||||
на одном соединении (оба думают, что они «внешние», оба зовут `PDO::beginTransaction()` — исключение);
|
на одном соединении (оба думают, что они «внешние», оба зовут `PDO::beginTransaction()` — исключение);
|
||||||
в Bicycle транзакции делегируются в `PdoDriver`, у которого счётчик один на всё подключение.
|
в Bicycle транзакции делегируются в `PdoConnection`, у которого счётчик один на всё подключение.
|
||||||
|
|
||||||
`Model` (`Services\DataBase\Model`, `#[AllowDynamicProperties]`) — лёгкий пассивный носитель данных,
|
`Model` (`Services\DataBase\Model`, `#[AllowDynamicProperties]`) — лёгкий пассивный носитель данных,
|
||||||
это класс по умолчанию для `T` в `Repository`. Свойства настоящие (не спрятаны за внутренним массивом) —
|
это класс по умолчанию для `T` в `Repository`. Свойства настоящие (не спрятаны за внутренним массивом) —
|
||||||
@ -627,7 +642,7 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap
|
|||||||
| `tests/Unit/FileAuthDriverTest.php` | `login()` верно/неверно/неизвестный логин, `loggedIn()`, `getUser()` без пароля, `logout()`, `checkPassword()`, отсутствие файла |
|
| `tests/Unit/FileAuthDriverTest.php` | `login()` верно/неверно/неизвестный логин, `loggedIn()`, `getUser()` без пароля, `logout()`, `checkPassword()`, отсутствие файла |
|
||||||
| `tests/Unit/AuthTest.php` | `instance()` драйвер по умолчанию/явный, кэширование по драйверу, неизвестный драйвер → исключение |
|
| `tests/Unit/AuthTest.php` | `instance()` драйвер по умолчанию/явный, кэширование по драйверу, неизвестный драйвер → исключение |
|
||||||
| `tests/Unit/DatabaseTest.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/StatementTest.php` | `showQuery()`/`sq()` — подстановка позиционных/именованных параметров — требует живую MariaDB |
|
||||||
| `tests/Unit/ProfilerTest.php` | `log()/entries()/totalTime()/count()/reset()` |
|
| `tests/Unit/ProfilerTest.php` | `log()/entries()/totalTime()/count()/reset()` |
|
||||||
| `tests/Unit/RepositoryTest.php` | `get()/getItemWhere()/getList()` (в т.ч. дефолтный `FETCH_CLASS` + `obj_class`)/`create()/update()/delete()/deleteWhere()`, `transaction()` с rollback — требует живую MariaDB |
|
| `tests/Unit/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/MongoDriverTest.php` | `instance()`, `collection()`, `database()` — требует `ext-mongodb`, иначе `markTestSkipped()` |
|
||||||
| `tests/Unit/ProfilerToolbarTest.php` | `render()` пусто для гостя/не-admin, панель с временем/памятью/SQL для admin, вкладки Vars/Files/Route, маскировка чувствительных ключей, вкладка Custom только при `addData()`, `EXPLAIN` для реального SELECT / пропуск для не-SELECT |
|
| `tests/Unit/ProfilerToolbarTest.php` | `render()` пусто для гостя/не-admin, панель с временем/памятью/SQL для admin, вкладки Vars/Files/Route, маскировка чувствительных ключей, вкладка Custom только при `addData()`, `EXPLAIN` для реального SELECT / пропуск для не-SELECT |
|
||||||
|
|
||||||
**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)
|
### Frontend dependencies (через Composer)
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* @package Bicycle
|
* @package Bicycle
|
||||||
* @author Egor Isaev
|
* @author Egor Isaev
|
||||||
* @description PdoDriver.php
|
* @description PdoConnection.php
|
||||||
* @copyright (c) 07/08/2026
|
* @copyright (c) 07/08/2026
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -17,8 +17,12 @@ use Throwable;
|
|||||||
/**
|
/**
|
||||||
* Обёртка над PDO: ленивое подключение (с перебором хостов при отказе — failover),
|
* Обёртка над PDO: ленивое подключение (с перебором хостов при отказе — failover),
|
||||||
* запросы через Statement (см. Statement::showQuery()) и вложенные транзакции через SAVEPOINT.
|
* запросы через 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()) */
|
/** @var PDO|null Подключение; null до первого обращения (connect()/pdo()) */
|
||||||
protected ?PDO $_pdo = null;
|
protected ?PDO $_pdo = null;
|
||||||
@ -27,14 +31,18 @@ class PdoDriver
|
|||||||
protected int $_transaction_level = 0;
|
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)
|
public function __construct(protected array $config)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Подключается к первому доступному хосту из host|hosts (failover).
|
* Подключается к первому доступному хосту из host|hosts (failover); для sqlite (нет
|
||||||
|
* понятия хоста — файл или ':memory:') подключается напрямую, без перебора.
|
||||||
* Не делает ничего, если подключение уже установлено.
|
* Не делает ничего, если подключение уже установлено.
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
@ -45,26 +53,24 @@ class PdoDriver
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$hosts = $this->config['hosts'] ?? [$this->config['host'] ?? 'localhost'];
|
$dialect = $this->config['dialect'] ?? 'mysql';
|
||||||
$port = $this->config['port'] ?? null;
|
$options = [
|
||||||
$dbname = $this->config['dbname'] ?? '';
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
$user = $this->config['user'] ?? '';
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
$password = $this->config['password'] ?? '';
|
];
|
||||||
$charset = $this->config['charset'] ?? 'utf8mb4';
|
|
||||||
|
|
||||||
|
if ($dialect === 'sqlite') {
|
||||||
|
$this->_pdo = $this->connectPdo($this->buildDsn($dialect, null), $options);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hosts = $this->config['hosts'] ?? [$this->config['host'] ?? 'localhost'];
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
|
||||||
foreach ($hosts as $host) {
|
foreach ($hosts as $host) {
|
||||||
$dsn = "mysql:host=$host;dbname=$dbname;charset=$charset" . ($port !== null ? ";port=$port" : '');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$pdo = new PDO($dsn, $user, $password, [
|
$this->_pdo = $this->connectPdo($this->buildDsn($dialect, $host), $options);
|
||||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
||||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
||||||
]);
|
|
||||||
$pdo->setAttribute(PDO::ATTR_STATEMENT_CLASS, [Statement::class, [$this]]);
|
|
||||||
|
|
||||||
$this->_pdo = $pdo;
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
} catch (PDOException $e) {
|
} 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 Подключается при первом обращении
|
* @return PDO Подключается при первом обращении
|
||||||
*/
|
*/
|
||||||
@ -177,7 +223,7 @@ class PdoDriver
|
|||||||
/**
|
/**
|
||||||
* Выполняет $callback в транзакции: begin → callback → commit;
|
* Выполняет $callback в транзакции: begin → callback → commit;
|
||||||
* при исключении — rollback и повторный throw. Вложенные вызовы (в том числе
|
* при исключении — rollback и повторный throw. Вложенные вызовы (в том числе
|
||||||
* из разных Repository на одном PdoDriver) используют SAVEPOINT прозрачно.
|
* из разных Repository на одном PdoConnection) используют SAVEPOINT прозрачно.
|
||||||
*
|
*
|
||||||
* @param callable $callback
|
* @param callable $callback
|
||||||
* @return mixed Результат $callback
|
* @return mixed Результат $callback
|
||||||
@ -13,10 +13,10 @@ use PDOStatement;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Расширение PDOStatement, подключается через PDO::ATTR_STATEMENT_CLASS
|
* Расширение PDOStatement, подключается через PDO::ATTR_STATEMENT_CLASS
|
||||||
* (см. PdoDriver::connect()). Добавляет showQuery()/sq() — PDOStatement::debugDumpParams()
|
* (см. PdoConnection::connect()). Добавляет showQuery()/sq() — PDOStatement::debugDumpParams()
|
||||||
* печатает в stdout, а не возвращает строку, поэтому значения bind-параметров
|
* печатает в stdout, а не возвращает строку, поэтому значения bind-параметров
|
||||||
* копятся вручную (bindValue()/execute() переопределены) и подставляются в SQL сами.
|
* копятся вручную (bindValue()/execute() переопределены) и подставляются в SQL сами.
|
||||||
* execute() также профилирует время в Profiler — здесь, а не в PdoDriver::query(),
|
* execute() также профилирует время в Profiler — здесь, а не в PdoConnection::query(),
|
||||||
* потому что через ATTR_STATEMENT_CLASS проходит вообще любое выполнение запроса,
|
* потому что через ATTR_STATEMENT_CLASS проходит вообще любое выполнение запроса,
|
||||||
* в том числе ручной $pdo->prepare()->execute() в обход query() (как в Repository).
|
* в том числе ручной $pdo->prepare()->execute() в обход query() (как в Repository).
|
||||||
*/
|
*/
|
||||||
@ -34,9 +34,9 @@ class Statement extends PDOStatement
|
|||||||
/**
|
/**
|
||||||
* Конструктор вызывается только изнутри PDO (PDO::ATTR_STATEMENT_CLASS) — не создавать напрямую.
|
* Конструктор вызывается только изнутри 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;
|
$sql = $this->queryString;
|
||||||
|
|
||||||
foreach ($this->_bound_params as $key => $value) {
|
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)) {
|
if (is_int($key)) {
|
||||||
$pos = strpos($sql, '?');
|
$pos = strpos($sql, '?');
|
||||||
|
|||||||
@ -8,32 +8,33 @@
|
|||||||
|
|
||||||
namespace Services;
|
namespace Services;
|
||||||
|
|
||||||
use Services\DataBase\Classes\PdoDriver;
|
use Services\DataBase\Classes\PdoConnection;
|
||||||
use System\Classes\Config;
|
use System\Classes\Config;
|
||||||
use System\Classes\MyException;
|
use System\Classes\MyException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Точка входа в реляционную БД. Имя аргумента — это имя подключения
|
* Точка входа в реляционную БД. Имя аргумента — это имя подключения
|
||||||
* (ключ в Config::get('db', $name), по умолчанию 'default'), а не тип драйвера —
|
* (ключ в Config::get('db', $name), по умолчанию 'default'). Подключение всегда через PDO —
|
||||||
* сейчас есть только один драйвер ('pdo'), поэтому реестр по типу драйвера
|
* PDO не «драйвер» в смысле выбора между несколькими реализациями (это и есть сама обёртка
|
||||||
* (как в Auth::$_drivers) не заводится, пока не появится второй.
|
* над клиентской библиотекой конкретной СУБД, см. CLAUDE.md, раздел DataBase), поэтому в
|
||||||
|
* конфиге нет отдельного ключа под тип драйвера — только параметры подключения.
|
||||||
*
|
*
|
||||||
* Database::instance()->query('SELECT ...');
|
* Database::instance()->query('SELECT ...');
|
||||||
* Database::instance('reporting')->query(...); // отдельное подключение
|
* Database::instance('reporting')->query(...); // отдельное подключение
|
||||||
*/
|
*/
|
||||||
class Database
|
class Database
|
||||||
{
|
{
|
||||||
/** @var array<string,PdoDriver> Кэш подключений по имени */
|
/** @var array<string,PdoConnection> Кэш подключений по имени */
|
||||||
protected static array $_instances = [];
|
protected static array $_instances = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Возвращает подключение по имени (кэшируется).
|
* Возвращает подключение по имени (кэшируется).
|
||||||
*
|
*
|
||||||
* @param string|null $name Имя подключения из конфига; null — 'default'
|
* @param string|null $name Имя подключения из конфига; null — 'default'
|
||||||
* @return PdoDriver
|
* @return PdoConnection
|
||||||
* @throws MyException Если подключение не описано в конфиге или драйвер неизвестен
|
* @throws MyException Если подключение не описано в конфиге
|
||||||
*/
|
*/
|
||||||
public static function instance(?string $name = null): PdoDriver
|
public static function instance(?string $name = null): PdoConnection
|
||||||
{
|
{
|
||||||
$name ??= 'default';
|
$name ??= 'default';
|
||||||
|
|
||||||
@ -44,13 +45,7 @@ class Database
|
|||||||
throw new MyException('Подключение к БД не описано в конфиге: :name', [':name' => $name]);
|
throw new MyException('Подключение к БД не описано в конфиге: :name', [':name' => $name]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (($config['driver'] ?? null) !== 'pdo') {
|
self::$_instances[$name] = new PdoConnection($config);
|
||||||
throw new MyException(
|
|
||||||
'Неизвестный драйвер БД: :driver', [':driver' => $config['driver'] ?? 'null']
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
self::$_instances[$name] = new PdoDriver($config['connection'] ?? []);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return self::$_instances[$name];
|
return self::$_instances[$name];
|
||||||
|
|||||||
@ -55,7 +55,7 @@ class ProfilerToolbar
|
|||||||
$time_ms = (microtime(true) - ($_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true))) * 1000;
|
$time_ms = (microtime(true) - ($_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true))) * 1000;
|
||||||
$memory = memory_get_peak_usage(true);
|
$memory = memory_get_peak_usage(true);
|
||||||
// Захватываем ДО sqlTab(): EXPLAIN внутри неё сам логируется в Profiler (обычный query()
|
// Захватываем ДО sqlTab(): EXPLAIN внутри неё сам логируется в Profiler (обычный query()
|
||||||
// через тот же PdoDriver) — иначе шапка "SQL: N (X ms)" включила бы наши же debug-запросы.
|
// через тот же PdoConnection) — иначе шапка "SQL: N (X ms)" включила бы наши же debug-запросы.
|
||||||
$query_count = Profiler::count();
|
$query_count = Profiler::count();
|
||||||
$query_time = Profiler::totalTime();
|
$query_time = Profiler::totalTime();
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ namespace System\Classes;
|
|||||||
use JsonException;
|
use JsonException;
|
||||||
use PDO;
|
use PDO;
|
||||||
use Services\Database;
|
use Services\Database;
|
||||||
use Services\DataBase\Classes\PdoDriver;
|
use Services\DataBase\Classes\PdoConnection;
|
||||||
use Services\DataBase\Model;
|
use Services\DataBase\Model;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
@ -21,7 +21,7 @@ use Throwable;
|
|||||||
* Конкретный репозиторий — в App\Repositories\* (например App\Repositories\BudgetRepository
|
* Конкретный репозиторий — в App\Repositories\* (например App\Repositories\BudgetRepository
|
||||||
* extends Repository), таблица/класс строки задаются через конструктор:
|
* extends Repository), таблица/класс строки задаются через конструктор:
|
||||||
* `parent::__construct('budget', Budget::class)` (по умолчанию — Services\DataBase\Model).
|
* `parent::__construct('budget', Budget::class)` (по умолчанию — Services\DataBase\Model).
|
||||||
* Подключение — $this->pdo (PdoDriver, не сырой \PDO — но с тем же query()/prepare()/...
|
* Подключение — $this->pdo (PdoConnection, не сырой \PDO — но с тем же query()/prepare()/...
|
||||||
* API, включая fetch-методы через Statement).
|
* API, включая fetch-методы через Statement).
|
||||||
*/
|
*/
|
||||||
abstract class Repository
|
abstract class Repository
|
||||||
@ -32,8 +32,8 @@ abstract class Repository
|
|||||||
/** @var bool Первичный ключ — автоинкремент (влияет на processData()/create()) */
|
/** @var bool Первичный ключ — автоинкремент (влияет на processData()/create()) */
|
||||||
public bool $is_auto_increment = true;
|
public bool $is_auto_increment = true;
|
||||||
|
|
||||||
/** @var PdoDriver Подключение */
|
/** @var PdoConnection Подключение */
|
||||||
protected PdoDriver $pdo;
|
protected PdoConnection $pdo;
|
||||||
|
|
||||||
/** @var string Таблица, с которой работает репозиторий */
|
/** @var string Таблица, с которой работает репозиторий */
|
||||||
protected string $table;
|
protected string $table;
|
||||||
@ -54,7 +54,7 @@ abstract class Repository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Начинает транзакцию; на вложенном уровне — SAVEPOINT (см. PdoDriver — он же считает
|
* Начинает транзакцию; на вложенном уровне — SAVEPOINT (см. PdoConnection — он же считает
|
||||||
* вложенность: у него общий счётчик на всё подключение, а не свой на каждый Repository,
|
* вложенность: у него общий счётчик на всё подключение, а не свой на каждый Repository,
|
||||||
* поэтому несколько репозиториев на одном Database::instance() безопасно участвуют
|
* поэтому несколько репозиториев на одном Database::instance() безопасно участвуют
|
||||||
* в одной внешней транзакции).
|
* в одной внешней транзакции).
|
||||||
|
|||||||
110
schema.sql
Normal file
110
schema.sql
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
-- Схема БД проекта «Бюджет» (MariaDB/InnoDB).
|
||||||
|
-- Модель согласована в .claude/memory/бюджет_текущий_план.md — сверяться туда при любых изменениях.
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
login VARCHAR(64) NOT NULL,
|
||||||
|
password VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
email VARCHAR(128) NULL,
|
||||||
|
date_add DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_login DATETIME NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_users_login (login)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE accounts (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
|
type_acc ENUM ('cash', 'bank', 'savings') NOT NULL,
|
||||||
|
title VARCHAR(128) NOT NULL,
|
||||||
|
summa DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
currency CHAR(3) NOT NULL DEFAULT 'RUB', -- ISO 4217; курс — Services\CurrencyRate (ЦБ РФ), не хранится тут
|
||||||
|
description VARCHAR(255) NULL, -- свободное поле, например "счёт жены" — без отдельной структуры
|
||||||
|
include_in_total TINYINT(1) NOT NULL DEFAULT 1, -- чисто ручной переключатель пользователя, не системное правило по типу/валюте (дефолт 0 для savings — просто стартовое значение)
|
||||||
|
is_delete TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`order` INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY ix_accounts_user_id (user_id),
|
||||||
|
CONSTRAINT fk_accounts_user FOREIGN KEY (user_id) REFERENCES users (id)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Ставка savings-счёта хранится историей, а не полем в accounts — старые периоды не должны
|
||||||
|
-- меняться задним числом при смене ставки; та же структура пригодится для кредитов позже.
|
||||||
|
CREATE TABLE account_rates (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
account_id INT UNSIGNED NOT NULL,
|
||||||
|
rate_percent DECIMAL(5, 2) NOT NULL,
|
||||||
|
valid_from DATE NOT NULL,
|
||||||
|
valid_to DATE NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY ix_account_rates_account_id (account_id),
|
||||||
|
CONSTRAINT fk_account_rates_account FOREIGN KEY (account_id) REFERENCES accounts (id)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Необязательная надстройка над savings-счётом: без записи здесь — просто копилка без цели.
|
||||||
|
CREATE TABLE goals (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
account_id INT UNSIGNED NOT NULL,
|
||||||
|
target_summa DECIMAL(15, 2) NOT NULL,
|
||||||
|
deadline DATE NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_goals_account_id (account_id),
|
||||||
|
CONSTRAINT fk_goals_account FOREIGN KEY (account_id) REFERENCES accounts (id)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE categories (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
|
parent_id INT UNSIGNED NULL,
|
||||||
|
title VARCHAR(128) NOT NULL,
|
||||||
|
type ENUM ('income', 'expense') NOT NULL,
|
||||||
|
is_delete TINYINT(1) NOT NULL DEFAULT 0, -- архивация вместо удаления, тот же приём что у accounts — сохраняет историю транзакций
|
||||||
|
`order` INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY ix_categories_user_id (user_id),
|
||||||
|
KEY ix_categories_parent_id (parent_id),
|
||||||
|
CONSTRAINT fk_categories_user FOREIGN KEY (user_id) REFERENCES users (id),
|
||||||
|
-- удаление родителя не должно тянуть за собой подкатегории — они просто станут верхнего уровня
|
||||||
|
CONSTRAINT fk_categories_parent FOREIGN KEY (parent_id) REFERENCES categories (id) ON DELETE SET NULL
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- categorie_id NULL — обычный transfer (в т.ч. пополнение копилки/цели): деньги просто
|
||||||
|
-- переехали между своими счетами, план/факт по категориям это не видит (см. бюджет_текущий_план.md).
|
||||||
|
-- account_f_id/account_in_id: income заполняет только _in, expense — только _f, transfer — оба.
|
||||||
|
CREATE TABLE transactions (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
|
categorie_id INT UNSIGNED NULL,
|
||||||
|
account_f_id INT UNSIGNED NULL,
|
||||||
|
account_in_id INT UNSIGNED NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
summa DECIMAL(15, 2) NOT NULL,
|
||||||
|
currency CHAR(3) NULL, -- валюта операции (обычно = валюте счёта списания); NULL = рубли
|
||||||
|
rate_to_rub DECIMAL(10, 4) NULL, -- курс ЦБ РФ, зафиксированный на момент операции; NULL для рублёвых
|
||||||
|
type ENUM ('income', 'expense', 'transfer') NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY ix_transactions_user_date (user_id, date),
|
||||||
|
KEY ix_transactions_categorie_id (categorie_id),
|
||||||
|
KEY ix_transactions_account_f_id (account_f_id),
|
||||||
|
KEY ix_transactions_account_in_id (account_in_id),
|
||||||
|
CONSTRAINT fk_transactions_user FOREIGN KEY (user_id) REFERENCES users (id),
|
||||||
|
CONSTRAINT fk_transactions_categorie FOREIGN KEY (categorie_id) REFERENCES categories (id),
|
||||||
|
CONSTRAINT fk_transactions_account_f FOREIGN KEY (account_f_id) REFERENCES accounts (id),
|
||||||
|
CONSTRAINT fk_transactions_account_in FOREIGN KEY (account_in_id) REFERENCES accounts (id)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Факт не хранится — считается на лету суммой transactions по категории за месяц.
|
||||||
|
CREATE TABLE budgets (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
categorie_id INT UNSIGNED NOT NULL,
|
||||||
|
plan_summ DECIMAL(15, 2) NOT NULL,
|
||||||
|
year SMALLINT UNSIGNED NOT NULL,
|
||||||
|
month TINYINT UNSIGNED NOT NULL,
|
||||||
|
type ENUM ('income', 'expense') NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_budgets_categorie_period (categorie_id, year, month),
|
||||||
|
CONSTRAINT fk_budgets_categorie FOREIGN KEY (categorie_id) REFERENCES categories (id)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
|
||||||
@ -5,7 +5,7 @@ namespace Tests\Unit;
|
|||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use ReflectionClass;
|
use ReflectionClass;
|
||||||
use Services\Database;
|
use Services\Database;
|
||||||
use Services\DataBase\Classes\PdoDriver;
|
use Services\DataBase\Classes\PdoConnection;
|
||||||
use System\Classes\Config;
|
use System\Classes\Config;
|
||||||
use System\Classes\MyException;
|
use System\Classes\MyException;
|
||||||
|
|
||||||
@ -25,9 +25,9 @@ class DatabaseTest extends TestCase
|
|||||||
$prop->setValue(null, null);
|
$prop->setValue(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
|
public function testInstanceIsCachedPerConnectionName(): void
|
||||||
@ -38,8 +38,8 @@ class DatabaseTest extends TestCase
|
|||||||
public function testDifferentConnectionNamesAreDistinctInstances(): void
|
public function testDifferentConnectionNamesAreDistinctInstances(): void
|
||||||
{
|
{
|
||||||
Config::set('db', [
|
Config::set('db', [
|
||||||
'default' => ['driver' => 'pdo', 'connection' => ['host' => 'db', 'dbname' => 'a']],
|
'default' => ['host' => 'db', 'dbname' => 'a'],
|
||||||
'second' => ['driver' => 'pdo', 'connection' => ['host' => 'db', 'dbname' => 'b']],
|
'second' => ['host' => 'db', 'dbname' => 'b'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertNotSame(Database::instance('default'), Database::instance('second'));
|
$this->assertNotSame(Database::instance('default'), Database::instance('second'));
|
||||||
@ -50,12 +50,4 @@ class DatabaseTest extends TestCase
|
|||||||
$this->expectException(MyException::class);
|
$this->expectException(MyException::class);
|
||||||
Database::instance('nonexistent');
|
Database::instance('nonexistent');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testUnknownDriverThrows(): void
|
|
||||||
{
|
|
||||||
Config::set('db', ['bad' => ['driver' => 'mysqli', 'connection' => []]]);
|
|
||||||
|
|
||||||
$this->expectException(MyException::class);
|
|
||||||
Database::instance('bad');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
164
tests/Unit/PdoConnectionTest.php
Normal file
164
tests/Unit/PdoConnectionTest.php
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Services\DataBase\Classes\PdoConnection;
|
||||||
|
use Services\DataBase\Classes\Profiler;
|
||||||
|
use System\Classes\Config;
|
||||||
|
use System\Classes\MyException;
|
||||||
|
|
||||||
|
class PdoConnectionTest extends TestCase
|
||||||
|
{
|
||||||
|
private PdoConnection $connection;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$config = Config::get('db', 'default') ?? [];
|
||||||
|
$this->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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,145 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Unit;
|
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use Services\DataBase\Classes\PdoDriver;
|
|
||||||
use Services\DataBase\Classes\Profiler;
|
|
||||||
use System\Classes\Config;
|
|
||||||
use System\Classes\MyException;
|
|
||||||
|
|
||||||
class PdoDriverTest extends TestCase
|
|
||||||
{
|
|
||||||
private PdoDriver $driver;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
$config = Config::get('db', 'default')['connection'] ?? [];
|
|
||||||
$this->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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -9,7 +9,7 @@ class ProfilerTest extends TestCase
|
|||||||
{
|
{
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
// Другие тесты (PdoDriverTest/StatementTest/RepositoryTest) пишут в этот же
|
// Другие тесты (PdoConnectionTest/StatementTest/RepositoryTest) пишут в этот же
|
||||||
// статический реестр, если есть живое подключение к БД — сбрасываем перед своими тестами.
|
// статический реестр, если есть живое подключение к БД — сбрасываем перед своими тестами.
|
||||||
Profiler::reset();
|
Profiler::reset();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ use PDO;
|
|||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use ReflectionClass;
|
use ReflectionClass;
|
||||||
use Services\Database;
|
use Services\Database;
|
||||||
use Services\DataBase\Classes\PdoDriver;
|
use Services\DataBase\Classes\PdoConnection;
|
||||||
use Services\DataBase\Classes\Profiler;
|
use Services\DataBase\Classes\Profiler;
|
||||||
use System\Classes\Config;
|
use System\Classes\Config;
|
||||||
use System\Classes\MyException;
|
use System\Classes\MyException;
|
||||||
@ -22,22 +22,22 @@ class RepositoryTestItemRepository extends Repository
|
|||||||
|
|
||||||
class RepositoryTest extends TestCase
|
class RepositoryTest extends TestCase
|
||||||
{
|
{
|
||||||
private PdoDriver $driver;
|
private PdoConnection $connection;
|
||||||
private RepositoryTestItemRepository $repository;
|
private RepositoryTestItemRepository $repository;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$config = Config::get('db', 'default')['connection'] ?? [];
|
$config = Config::get('db', 'default') ?? [];
|
||||||
$this->driver = new PdoDriver($config);
|
$this->connection = new PdoConnection($config);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->driver->pdo();
|
$this->connection->pdo();
|
||||||
} catch (MyException $e) {
|
} catch (MyException $e) {
|
||||||
$this->markTestSkipped('Нет живого подключения к MariaDB (заполните App/config/config.local.php): ' . $e->getMessage());
|
$this->markTestSkipped('Нет живого подключения к MariaDB (заполните App/config/config.local.php): ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->driver->exec('DROP TABLE IF EXISTS repository_test_items');
|
$this->connection->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('CREATE TABLE repository_test_items (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255), qty INT)');
|
||||||
|
|
||||||
$this->repository = new RepositoryTestItemRepository();
|
$this->repository = new RepositoryTestItemRepository();
|
||||||
}
|
}
|
||||||
@ -46,9 +46,9 @@ class RepositoryTest extends TestCase
|
|||||||
{
|
{
|
||||||
Profiler::reset();
|
Profiler::reset();
|
||||||
|
|
||||||
if (isset($this->driver)) {
|
if (isset($this->connection)) {
|
||||||
try {
|
try {
|
||||||
$this->driver->exec('DROP TABLE IF EXISTS repository_test_items');
|
$this->connection->exec('DROP TABLE IF EXISTS repository_test_items');
|
||||||
} catch (MyException) {
|
} catch (MyException) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,22 +3,22 @@
|
|||||||
namespace Tests\Unit;
|
namespace Tests\Unit;
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Services\DataBase\Classes\PdoDriver;
|
use Services\DataBase\Classes\PdoConnection;
|
||||||
use Services\DataBase\Classes\Profiler;
|
use Services\DataBase\Classes\Profiler;
|
||||||
use System\Classes\Config;
|
use System\Classes\Config;
|
||||||
use System\Classes\MyException;
|
use System\Classes\MyException;
|
||||||
|
|
||||||
class StatementTest extends TestCase
|
class StatementTest extends TestCase
|
||||||
{
|
{
|
||||||
private PdoDriver $driver;
|
private PdoConnection $connection;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$config = Config::get('db', 'default')['connection'] ?? [];
|
$config = Config::get('db', 'default') ?? [];
|
||||||
$this->driver = new PdoDriver($config);
|
$this->connection = new PdoConnection($config);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->driver->pdo();
|
$this->connection->pdo();
|
||||||
} catch (MyException $e) {
|
} catch (MyException $e) {
|
||||||
$this->markTestSkipped('Нет живого подключения к MariaDB (заполните App/config/config.local.php): ' . $e->getMessage());
|
$this->markTestSkipped('Нет живого подключения к MariaDB (заполните App/config/config.local.php): ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
@ -31,21 +31,21 @@ class StatementTest extends TestCase
|
|||||||
|
|
||||||
public function testShowQuerySubstitutesPositionalParams(): void
|
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());
|
$this->assertSame("SELECT 'foo' AS a, 42 AS b", $statement->showQuery());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testShowQuerySubstitutesNamedParams(): void
|
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());
|
$this->assertSame("SELECT 'bicycle' AS name", $statement->showQuery());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSqIsAliasForShowQuery(): void
|
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());
|
$this->assertSame($statement->showQuery(), $statement->sq());
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user