handbookНаписать

patterns/creational/builder/practice/README.md · практика · 4 языка · 25 тестов

база: Строитель

Практика: запрос отчёта (Report query) ​

Строитель SQL-запроса для отчётов: значения только в параметрах, пагинация с границами, и неизменяемость, из-за которой базовый запрос можно делить между отчётами без сюрпризов.

Базовый пример: Строитель. Там строитель изменяемый, и это нормально, пока им пользуется один вызов. В жизни строитель хранят, передают и переиспользуют, и первое, что ломается, — общий «базовый» запрос, который один отчёт незаметно дополняет для другого.

Задача ​

В личном кабинете несколько отчётов по заказам клиента: неоплаченные, история с постраничной навигацией, заказы за период. Все они начинаются одинаково: заказы этого клиента, новые сверху. Отличаются фильтры и страница.

Первая версия склеивала SQL строками: значения попадали в запрос напрямую, LIMIT приходил из URL без проверки, а общий $base запрос хранился в сервисе и дополнялся методами. Однажды отчёт «неоплаченные» добавил в него status = 'unpaid', и история заказов начала показывать только неоплаченные.

Что урезано ​

  • Запрос не выполняется: продукт — строка SQL и список параметров. С настоящей БД это ушло бы в PDO/database/sql.
  • Одна таблица, четыре фильтра, одна сортировка.
  • Нет диалектов SQL и экранирования идентификаторов: имена колонок зашиты в шаги.

Решение ​

Продукт Query — SQL с плейсхолдерами ? и параметры в их порядке. Значение никогда не попадает в строку: шаг добавляет условие с ? и кладёт значение в параметры.

Строитель OrdersQuery неизменяемый: каждый шаг возвращает новый экземпляр с дополненным состоянием. Проверки живут в шагах: перевёрнутый период и неразумный размер страницы отвергаются сразу, build() только склеивает. Пагинация включена всегда, с умолчанием и верхней границей.

Директор OrderReports держит общий базовый запрос и строит из него отчёты. Благодаря неизменяемости он безопасен: добавление status в одном отчёте не меняет базу для другого.

Структура ​

shares base

builds

every step returns a copy

Query

+sql: string

+params: list

OrdersQuery

-where: string[]

-params: list

-orderBy: string

-limit: int

-offset: int

+all() : OrdersQuery

+status(s) : OrdersQuery

+customer(id) : OrdersQuery

+createdBetween(from, to) : OrdersQuery

+newestFirst() : OrdersQuery

+page(n, size) : OrdersQuery

+build() : Query

OrderReports

-forCustomer(id) : OrdersQuery

+unpaid(id) : Query

+history(id, page) : Query

Продукт разделяет 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() возвращает базу, каждый отчёт дополняет её, и ни один не видит чужих правок. С изменяемым строителем такой директор потребовал бы клонирования на каждый вызов и всё равно был бы ловушкой.

ЯзыкОсобенность
PHPfinal readonly строитель с приватным конструктором и with(...) через именованные аргументы; [...$this->where, $cond] создаёт новый массив
GoПриёмники-значения копируют структуру, но не срезы: slices.Clone обязателен; ошибки шагов едут по цепочке и всплывают в Build()
Pythonfrozen 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> для патча делает шаги однострочными. Приватный конструктор не даёт обойти умолчания пагинации.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS