В современной разработке программного обеспечения работа с базами данных — это фундамент практически любого приложения․ Написание «сырого» SQL-кода вручную часто приводит к ошибкам, уязвимостям (SQL-инъекции) и сложностям в поддержке кода․ Именно здесь на сцену выходит Конструктор запросов (Query Builder) — инструмент, позволяющий формировать SQL-запросы программно, используя методы и объекты языка программирования․
В этой статье мы подробно разберем, как составить конструктор запросов самостоятельно, какие паттерны использовать, как обеспечить безопасность и расширяемость, а также рассмотрим примеры реализации на псевдокоде, применимом к PHP, Python, JavaScript/TypeScript, Go или Java․
Зачем нужен свой Query Builder?
Прежде чем писать код, важно понять мотивацию․ Готовые ORM (Eloquent, Doctrine, Hibernate, SQLAlchemy) часто избыточны для высоконагруженных микросервисов или специфических задач․ Свой конструктор дает:
- Полный контроль над SQL: Никаких скрытых JOIN’ов или лишних SELECT *․
- Безопасность «из коробки»: Автоматическая параметризация значений (Prepared Statements)․
- Читаемость и типизация: IDE подсказывает методы, проверяет типы колонок (если использовать типизированные подходы)․
- Отсутствие зависимостей: Легковесная библиотека под конкретные нужды проекта․
- Тестируемость: Легко мокать и проверять сгенерированный SQL без подключения к реальной БД․
Архитектурные паттерны: Fluent Interface и Builder
Основа любого удобного конструктора — это Fluent Interface (Плавный интерфейс) в сочетании с паттерном Builder (Строитель)․
Принцип работы:
- Создается объект-контекст запроса (
QueryBuilder)․ - Методы (
select,where,join) модифицируют внутреннее состояние объекта․ - Каждый метод возвращает
$this(илиself), позволяя цепочечно вызывать методы․ - Финальный метод (
getSQL,execute) компилирует состояние в строку SQL и массив параметров․
Ключевые компоненты класса:
- Свойства-хранилища: Массивы для частей запроса:
_select,_from,_joins,_wheres,_groups,_havings,_orders,_limit,_offset,_bindings․ - Грамматика (Grammar/Compiler): Отдельный класс, отвечающий за превращение массивов в строки SQL для конкретного диалекта (MySQL, PostgreSQL, SQLite, SQL Server)․ Это позволяет сменить БД, не меняя логику билдера․
Пошаговая реализация ядра (Псевдокод / TypeScript-подобный стиль)
Ниже приведена упрощенная, но работоспособная архитектура․ Реальный код требует обработки edge-cases (экранирование идентификаторов, подзапросы, объединения)․
Базовый класс и Select
class QueryBuilder {
protected array $columns = ['*'];
protected string $table = '';
protected array $wheres = [];
protected array $joins = [];
protected array $bindings = [];
protected ?int $limit = null;
protected ?int $offset = null;
protected array $orders = [];
protected array $groups = [];
// Установка таблицы
public function table(string $table): self {
$this->table = $table;
return $this;
}
// Выбор колонок
public function select(string|array $columns = ['*']): self {
$this->columns = is_array($columns) ? $columns : func_get_args;
return $this;
}
// Добавление условия WHERE
public function where(string $column, string $operator, mixed $value, string $boolean = 'and'): self {
// Валидация операторов для безопасности
$allowedOperators = ['=', '!=', '<>', '<', '>', '<=', '>=', 'like', 'not like', 'in', 'not in', 'between', 'is', 'is not'];
if (!in_array(strtolower($operator), $allowedOperators)) {
throw new InvalidArgumentException("Недопустимый оператор: $operator");
}
$this->wheres[] = [
'type' => 'basic',
'column' => $column,
'operator' => $operator,
'value' => $value,
'boolean' => $boolean,
];
// Сохраняем значение для биндинга (позже)
// В реальности биндинги собираются на этапе компиляции
return $this;
}
// JOIN таблиц
public function join(string $table, string $first, string $operator, string $second, string $type = 'inner'): self {
$this->joins[] = compact('type', 'table', 'first', 'operator', 'second');
return $this;
}
// LIMIT / OFFSET
public function limit(int $value): self { $this->limit = $value; return $this; }
public function offset(int $value): self { $this->offset = $value; return $this; }
// ORDER BY
public function orderBy(string $column, string $direction = 'asc'): self {
$this->orders[] = ['column' => $column, 'direction' => strtolower($direction)];
return $this;
}
Компилятор (Grammar) — сердце безопасности
Никогда не конкатенируйте значения прямо в строку SQL․ Всегда используйте плейсхолдеры (? или :name)․
class Grammar {
// Компиляция всего SELECT запроса
public function compileSelect(QueryBuilder $builder): array {
$sql = 'SELECT ';
// 1․ Columns
$sql ․= $this->compileColumns($builder->columns);
// 2․ From
$sql ․= ' FROM ' ․ $this->wrapTable($builder->table);
// 3․ Joins
if ($builder->joins) {
$sql ․= ' ' ․ $this->compileJoins($builder);
}
// 4․ Wheres
$bindings = [];
if ($builder->wheres) {
$whereSql = $this->compileWheres($builder, $bindings);
$sql ․= ' WHERE ' ․ $whereSql;
}
// 5․ Group By / Having
if ($builder->groups) {
$sql ․= ' GROUP BY ' ․ implode(', ', array_map([$this, 'wrap'], $builder->groups));
}
// 6․ Order By
if ($builder->orders) {
$sql ․= ' ORDER BY ' ․ $this->compileOrders($builder);
}
// 7․ Limit / Offset
if ($builder->limit !== null) {
$sql ․= ' LIMIT ' ․ (int)$builder->limit;
}
if ($builder->offset !== null) {
$sql ․= ' OFFSET ' ․ (int)$builder->offset;
}
return ['sql' => $sql, 'bindings' => $bindings];
}
// Оборачивание идентификаторов в кавычки (Backticks для MySQL, Double quotes для PG)
protected function wrap(string $value): string {
// Простая реализация для MySQL
if (strpos($value, '․') !== false) {
$parts = explode('․', $value);
return implode('․', array_map(fn($p) => "`$p`", $parts));
}
return "`$value`";
}
protected function wrapTable(string $table): string {
return $this->wrap($table);
}
protected function compileColumns(array $columns): string {
if (count($columns) === 1 && $columns[0] === '') return '';
return implode(', ', array_map([$this, 'wrap'], $columns));
}
protected function compileWheres(QueryBuilder $builder, array &$bindings): string {
$sqlParts = [];
foreach ($builder->wheres as $index => $where) {
$boolean = $index === 0 ? '' : strtoupper($where['boolean']) ․ ' ';
if ($where['type'] === 'basic') {
$column = $this->wrap($where['column']);
$operator = strtoupper($where['operator']);
$value = $where['value'];
// Обработка IN / NOT IN
if (in_array($operator, ['IN', 'NOT IN']) && is_array($value)) {
$placeholders = implode(', ', array_fill(0, count($value), '?'));
$sqlParts[] = "$boolean$column $operator ($placeholders)";
$bindings = array_merge($bindings, $value);
}
// Обработка BETWEEN
elseif ($operator === 'BETWEEN' && is_array($value) && count($value) === 2) {
$sqlParts[] = "$boolean$column $operator ? AND ?";
$bindings = array_merge($bindings, $value);
}
// Обработка IS NULL / IS NOT NULL
elseif (in_array($operator, ['IS', 'IS NOT']) && $value === null) {
$sqlParts[] = "$boolean$column $operator NULL";
}
// Стандартное сравнение
else {
$sqlParts[] = "$boolean$column $operator ?";
$bindings[] = $value;
}
}
// TODO: Добавить поддержку вложенных where (closures), orWhere, whereRaw и т․д․
}
return implode(' ', $sqlParts);
}
protected function compileJoins(QueryBuilder $builder): string {
$sql = [];
foreach ($builder->joins as $join) {
$type = strtoupper($join['type']);
$table = $this->wrapTable($join['table']);
$first = $this->wrap($join['first']);
$second = $this->wrap($join['second']);
$operator = $join['operator'];
$sql[] = "$type JOIN $table ON $first $operator $second";
} return implode(' ', $sql);
}
protected function compileOrders(QueryBuilder $builder): string {
return implode(', ', array_map(function($order) {
return $this->wrap($order['column']) ․ ' ' ․ strtoupper($order['direction']);
}, $builder->orders));
}
} Исполнение (Connection / PDO Wrapper)
Конструктор не должен знать про PDO․ Он возвращает SQL и биндинги․ Исполнение — задача слоя соединения․
class DatabaseConnection {
private PDO $pdo;
private Grammar $grammar;
public function __construct(PDO $pdo, Grammar $grammar) {
$this->pdo = $pdo;
$this->grammar = $grammar;
}
public function select(QueryBuilder $builder): array {
[$sql, $bindings] = $this->grammar->compileSelect($builder);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($bindings);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
// Аналогично insert, update, delete
}
Продвинутые фичи: Как сделать конструктор профессиональным
Подзапросы (Subqueries) и Замыкания (Closures)
Для сложной логики (например, WHERE EXISTS или WHERE IN (SELECT․․․)) методы where должны принимать замыкание (callback), которому передается новый экземпляр QueryBuilder․
public function where(callable $callback, string $boolean = 'and'): self {
$subBuilder = new QueryBuilder($this->grammar); // или клонирование
$callback($subBuilder);
[$sql, $bindings] = $this->grammar->compileSelect($subBuilder);
$this->wheres[] = [
'type' => 'nested',
'query' => $subBuilder,
'boolean' => $boolean,
]; // Биндинги подзапроса нужно смержить в основной массив при компиляции
return $this;
}
Поддержка разных диалектов SQL (Strategy Pattern)
Класс Grammar должен быть интерфейсом (GrammarInterface)․ Реализации: MySqlGrammar, PostgresGrammar, SqliteGrammar, SqlServerGrammar․
- MySQL: Бэктики
`column`,LIMIT ? OFFSET ?․ - PostgreSQL: Двойные кавычки
"column",LIMIT ? OFFSET ?, поддержкаRETURNING *․ - SQL Server: Квадратные скобки
[column], пагинация черезOFFSET ? ROWS FETCH NEXT ? ROWS ONLY․
Типизированные колонки (Type-Safe Builders)
В строго типизированных языках (TypeScript, Go, Java, PHP 8․1+ с generics) можно использовать Entity/DTO классы для описания схемы таблицы․
// Пример концепции для TypeScript/PHP Generics
class UserTable {
public const string TABLE = 'users';
public const Column ID = 'id';
public const Column EMAIL = 'email';
// ․․․
}
// Использование
$builder->table(UserTable::TABLE)
->select([UserTable::ID, UserTable::EMAIL])
->where(UserTable::EMAIL, '=', '@․com'); // IDE подскажет колонки, не будет опечаток
Raw-выражения (Escape Hatch)
Иногда нужно вызвать функцию БД (NOW, COUNT(*), JSON_EXTRACT)․ Добавляйте класс RawExpression, который компилятор знает, как вывести «как есть» (но без значений пользователя!)․
class RawExpression {
public function __construct(public string $sql, public array $bindings = []) {}
}
// В билдере:
public function selectRaw(string $sql, array $bindings = []): self {
$this->columns[] = new RawExpression($sql, $bindings);
return $this;
}
Безопасность: Чек-лист при разработке
- Никакой конкатенации значений․ Только плейсхолдеры (
?/:name) и передача значений вexecute․ - Экранирование идентификаторов․ Колонки и таблицы оборачивайте в кавычки диалекта (
wrap)․ Не доверяйте входным данным для имен колонок вORDER BYилиSELECT— используйте «белый список» (allowlist) разрешенных колонок․ - Валидация операторов․ В методе
whereпроверяйте$operatorпротив жесткого списка разрешенных․ - Защита от Limit/Offset инъекций․ Приводите к
(int)перед вставкой в SQL․ - Логирование медленных запросов․ Обертка над
executeдолжна замерять время и логировать SQL с подставленными значениями (для дебага) в безопасном виде․
Пример использования готового конструктора
$db = new DatabaseConnection(new PDO('mysql:host=;dbname=', '', ''), new MySqlGrammar);
$users = $db->select(
(new QueryBuilder)
->table('users')
->select(['id', 'name', 'email', 'created_at'])
->join('profiles', 'users․id', '=', 'profiles․user_id', 'left')
->where('users․status', '=', 'active')
->where('users․role', 'in', ['admin', 'moderator', 'editor'])
->where('profiles․last_login_at', '>', '2023-01-01')
->orderBy('users․created_at', 'desc')
->limit(20)
->offset(0)
);
// Сгенерированный SQL (для MySQL):
// SELECT `id`, `name`, `email`, `created_at`
// FROM `users`
// LEFT JOIN `profiles` ON `users`․`id` = `profiles`․`user_id`
// WHERE `users`․`status` = ? AND `users`․`role` IN (?, ?, ?) AND `profiles`․`last_login_at` > ?
// ORDER BY `users`․`created_at` DESC LIMIT 20 OFFSET 0
// Bindings: ['active', 'admin', 'moderator', 'editor', '2023-01-01']
Тестирование конструктора
Поскольку билдер не требует живого соединения с БД для генерации SQL, тесты пишутся мгновенно (Unit Tests)․
- Снапшот-тестирование: Сравнивайте генерируемую SQL-строку с эталонным файлом (
․snap)․ - Проверка биндингов: Убеждайтесь, что порядок биндингов соответствует порядку плейсхолдеров․
- Edge cases: Пустые WHERE, вложенные OR/AND группы, спецсимволы в именах колонок, разные типы JOIN․
// Пример теста (PHPUnit / Pest)
test('compiles complex where with in and nested', function {
$builder = (new QueryBuilder)
->table('posts')
->where('status', '=', 'published')
->where(function($q) {
$q->where('author_id', '=', 1)
->orWhere('author_id', '=', 2);
});
[$sql, $bindings] = (new MySqlGrammar)->compileSelect($builder);
expect($sql)->toBe('SELECT * FROM `posts` WHERE `status` = ? AND (`author_id` = ? OR `author_id` = ?)');
expect($bindings)->toBe(['published', 1, 2]);
}); Расширение функционала: Insert, Update, Delete
Полноценный Query Builder покрывает все CRUD-операции․ Архитектура остается той же: отдельные методы compileInsert, compileUpdate, compileDelete в классе Grammar․
Пример Update:
// В QueryBuilder
public function update(array $values): self {
$this->type = 'update';
$this->updateValues = $values;
return $this;
}
// В Grammar
public function compileUpdate(QueryBuilder $builder): array {
$table = $this->wrapTable($builder->table);
$columns = array_keys($builder->updateValues);
$bindings = array_values($builder->updateValues);
$set = implode(', ', array_map(fn($col) => $this->wrap($col) ․ ' = ?', $columns));
$sql = "UPDATE $table SET $set";
// WHERE компилируется так же, как для SELECT
if ($builder->wheres) {
$whereSql = $this->compileWheres($builder, $bindings);
$sql ․= ' WHERE ' ․ $whereSql;
}
return ['sql' => $sql, 'bindings' => $bindings];
}
Интеграция в фреймворк / проект
Чтобы конструктор стал удобным инструментом, а не просто классом:
- Фасад / Хелпер: Глобальная функция
db->table('users')->getили статический фасадDB::table('users')->get․ - Модели (Active Record / Repository): Базовый класс
Model, который инкапсулируетQueryBuilderдля конкретной таблицы․ - Миграции/Сидеры: Используйте тот же Grammar для генерации DDL (CREATE TABLE, ALTER TABLE) — это гарантирует совместимость типов данных․
- События (Events): Добавьте хуки
queryExecuting,queryExecutedдля логирования, профилирования (Debugbar) и кэширования․
Составление собственного конструктора запросов (Query Builder) — это отличная архитектурная задача, которая глубоко прокачивает понимание работы БД, паттернов проектирования (Builder, Strategy, Fluent Interface) и принципов безопасности․
Ключевые выводы:
- Разделяйте сборку (QueryBuilder) и компиляцию (Grammar)․
- Всегда используйте параметризованные запросы (Prepared Statements)․
- Сделайте Grammar подменяемым для поддержки разных СУБД․
- Покройте генерацию SQL юнит-тестами без реальной БД․
- Добавляйте продвинутые фичи (подзапросы, Raw, типизация) итеративно, по мере необходимости проекта․
Начните с минимального набора (select, from, where, limit, get), напишите тесты, и расширяйте функционал только когда возникнет реальная бизнес-задача; Так вы получите легкий, быстрый и абсолютно понятный инструмент, идеально подходящий под вашу архитектуру․
Статья подготовлена в образовательных целях․ Примеры кода демонстрируют архитектурные принципы и требуют доработки (обработка исключений, транзакции, кэширование схемы) для продакшн-использования․ Контакты автора: @․* | Телефон: +7—-**