Конструктор запроса составить

Бесплатная консультация юриста

+7 (800) 301-07-30

Реклама jurik.ru

В современной разработке программного обеспечения работа с базами данных — это фундамент практически любого приложения․ Написание «сырого» 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 (Строитель)․

Принцип работы:

  1. Создается объект-контекст запроса (QueryBuilder)․
  2. Методы (select, where, join) модифицируют внутреннее состояние объекта․
  3. Каждый метод возвращает $this (или self), позволяя цепочечно вызывать методы․
  4. Финальный метод (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;
}

Безопасность: Чек-лист при разработке

  1. Никакой конкатенации значений․ Только плейсхолдеры (? / :name) и передача значений в execute․
  2. Экранирование идентификаторов․ Колонки и таблицы оборачивайте в кавычки диалекта (wrap)․ Не доверяйте входным данным для имен колонок в ORDER BY или SELECT — используйте «белый список» (allowlist) разрешенных колонок․
  3. Валидация операторов․ В методе where проверяйте $operator против жесткого списка разрешенных․
  4. Защита от Limit/Offset инъекций․ Приводите к (int) перед вставкой в SQL․
  5. Логирование медленных запросов․ Обертка над 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—-**