Практика: запрос отчёта (Report query)
Строитель SQL-запроса для отчётов: значения только в параметрах, пагинация с границами, и неизменяемость, из-за которой базовый запрос можно делить между отчётами без сюрпризов.
Базовый пример: Строитель. Там строитель изменяемый, и это нормально, пока им пользуется один вызов. В жизни строитель хранят, передают и переиспользуют, и первое, что ломается, — общий «базовый» запрос, который один отчёт незаметно дополняет для другого.
Задача
В личном кабинете несколько отчётов по заказам клиента: неоплаченные, история с постраничной навигацией, заказы за период. Все они начинаются одинаково: заказы этого клиента, новые сверху. Отличаются фильтры и страница.
Первая версия склеивала SQL строками: значения попадали в запрос напрямую, LIMIT приходил из URL без проверки, а общий $base запрос хранился в сервисе и дополнялся методами. Однажды отчёт «неоплаченные» добавил в него status = 'unpaid', и история заказов начала показывать только неоплаченные.
Что урезано
- Запрос не выполняется: продукт — строка SQL и список параметров. С настоящей БД это ушло бы в
PDO/database/sql. - Одна таблица, четыре фильтра, одна сортировка.
- Нет диалектов SQL и экранирования идентификаторов: имена колонок зашиты в шаги.
Решение
Продукт Query — SQL с плейсхолдерами ? и параметры в их порядке. Значение никогда не попадает в строку: шаг добавляет условие с ? и кладёт значение в параметры.
Строитель OrdersQuery неизменяемый: каждый шаг возвращает новый экземпляр с дополненным состоянием. Проверки живут в шагах: перевёрнутый период и неразумный размер страницы отвергаются сразу, build() только склеивает. Пагинация включена всегда, с умолчанием и верхней границей.
Директор OrderReports держит общий базовый запрос и строит из него отчёты. Благодаря неизменяемости он безопасен: добавление status в одном отчёте не меняет базу для другого.
Структура
Продукт разделяет SQL и значения: это не удобство, а защита. Тест с ' OR 1=1 -- показывает, что строка запроса не меняется, а подозрительное значение уезжает параметром.
Как это собирается
Запрос собирает контроллер страницы заказов. Базу OrdersQuery::all()->customer($customerId)->newestFirst() он строит один раз: клиент приходит из сессии, сортировка одна для всех отчётов. Дальше на каждый запрос берётся эта база и дополняется тем, что пришло в адресе: status, период from/to, страница и её размер. Фильтры добавляются только если они есть, а страница всегда: без page действуют умолчания контроллера. Готовый Query ушёл бы в PDO или database/sql.
Неизменяемость видна в выводе: status из первого запроса не попадает во второй, хотя база общая. Размер страницы 1000 отвергает шаг page(), и контроллер отвечает 400, не доходя до базы данных. В Go ошибка шага копится в строителе и возвращается из Build().
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Директор — то место, где неизменяемость окупается: forCustomer() возвращает базу, каждый отчёт дополняет её, и ни один не видит чужих правок. С изменяемым строителем такой директор потребовал бы клонирования на каждый вызов и всё равно был бы ловушкой.
| Язык | Особенность |
|---|---|
| PHP | final readonly строитель с приватным конструктором и with(...) через именованные аргументы; [...$this->where, $cond] создаёт новый массив |
| Go | Приёмники-значения копируют структуру, но не срезы: slices.Clone обязателен; ошибки шагов едут по цепочке и всплывают в Build() |
| Python | frozen dataclass + dataclasses.replace; кортежи вместо списков, иначе «неизменяемость» будет только по имени |
| TypeScript | Состояние в отдельном readonly объекте, шаги делают { ...state, ...patch }; приватный конструктор, вход через OrdersQuery.all() |
Запуск: make test T=patterns/creational/builder/practice
Что проверяют тесты
Тесты одинаковы во всех языках. Запрос без шагов — уже валидный постраничный запрос без параметров. Полная цепочка даёт точный SQL и параметры в порядке плейсхолдеров. Главный тест воспроизводит инцидент: отчёт «неоплаченные» и «история» строятся из одной базы, и в истории нет status. Четвёртый тест — значение с кавычкой остаётся параметром. Пятый — перевёрнутый период и размер страницы в десять тысяч отвергаются на шаге.
Где граница
- Таблиц и соединений стало много, появились диалекты — это задача ORM или библиотеки запросов, а не своего строителя. Свой строитель оправдан для узкого набора отчётов с фиксированной схемой.
- Шагов больше десятка и они зависят друг от друга («период только с сортировкой по дате») — пора в типизированные состояния (type-state) или разбить на несколько строителей.
- Неизменяемый строитель копирует состояние на каждом шаге. Для тысяч шагов это заметно; для запросов с пятью условиями нет.
- Если строитель никогда не делят, изменяемый проще: базовый пример.
Отличия между языками
- PHP —
readonlyкласс запрещает мутацию, поэтому приватный конструктор плюсwith(...)с именованными аргументами и значениямиnullкак «не менять». Спред массивов делает копии бесплатно. - Go — приёмник-значение даёт копию структуры, но срезы внутри указывают на ту же память: без
slices.Cloneдва отчёта запишут условия друг другу. Ошибки шагов нельзя бросить, поэтому первая сохраняется в строителе и возвращается изBuild(). - Python —
replaceвозвращает новыйfrozenэкземпляр; кортежи важны, потому что список внутри frozen dataclass изменяем. - TypeScript —
readonlyи спред объекта;Partial<State>для патча делает шаги однострочными. Приватный конструктор не даёт обойти умолчания пагинации.
Связанные темы
- Строитель (базовый пример)
- Практика Стратегии: расчёт доставки — другой ответ на «правила из данных»: не шаги сборки, а таблица правил