«Доктор, у меня Query Builder что-то странное генерирует…»
Знакомая ситуация: вы пишете элегантный цепной запрос на Query Builder, уверены, что всё работает идеально, но на выходе — либо ошибка, либо не те данные. Первое, что приходит в голову любому PHP-разработчику: «Покажи мне, что ты там на самом деле генерируешь!».
Вопрос «How do I get the query builder to output its raw SQL query as a string?» — один из самых популярных на Stack Overflow (тысячи просмотров и десятки ответов). И это не случайно. Умение быстро получить чистый SQL — это суперсила, которая экономит часы отладки.
В этой статье мы разберём 3 рабочих метода для PHP-фреймворков (Laravel, Doctrine, CakePHP), покажем реальные примеры кода и объясним, когда какой метод использовать. Поехали!
Почему это вообще нужно? (Кратко и по делу)
- Отладка — понять, почему запрос возвращает не те данные.
- Оптимизация — увидеть, какие JOIN'ы и WHERE'ы реально выполняются.
- Обучение — новички часто не понимают, как Query Builder транслирует методы в SQL.
- Логирование — сохранить сырой запрос для аудита или реплея.
Готовы? Тогда переходим к коду!
Метод 1: Laravel Eloquent / Query Builder — классика
В Laravel есть несколько способов, но самый надёжный — использовать toSql() вместе с getBindings(). Почему вместе? Потому что toSql() показывает запрос с плейсхолдерами ?, а без подстановки значений это бесполезно.
Простой пример: SELECT с WHERE
use App\Models\User;
$query = User::where('email', 'like', '%@gmail.com')
->where('active', 1)
->orderBy('created_at', 'desc');
// Получаем SQL с плейсхолдерами
$sql = $query->toSql();
$bindings = $query->getBindings();
// Собираем всё в читаемый вид
$fullSql = vsprintf(str_replace('?', '%s', $sql), $bindings);
echo $fullSql;
// Результат:
// SELECT * FROM users WHERE email LIKE '%@gmail.com' AND active = 1 ORDER BY created_at DESC
Важно: vsprintf — не панацея. Для сложных типов данных (например, NULL или массивы) лучше использовать Str::replaceArray() или пакет laravel-query-builder-debug.
Продвинутый вариант: с join и whereIn
$query = DB::table('orders')
->join('users', 'orders.user_id', '=', 'users.id')
->whereIn('orders.status', ['pending', 'processing'])
->where('orders.total', '>', 100)
->select('orders.*', 'users.name');
$bindings = $query->getBindings();
$sql = $query->toSql();
// Красиво форматируем
$fullSql = preg_replace_array('/\?/', $bindings, $sql);
echo $fullSql;
// SELECT orders.*, users.name FROM orders INNER JOIN users ON orders.user_id = users.id WHERE orders.status IN ('pending', 'processing') AND orders.total > 100
Совет: Используйте preg_replace_array() — он безопаснее, чем vsprintf, особенно когда в данных есть символ %.
Метод 2: Doctrine DBAL — для тех, кто любит строгость
Doctrine Query Builder (часто используется в Symfony) — зверь посложнее. Здесь нет готового toSql(), но есть getSQL() и getParameters(). Плюс нужно учитывать типы параметров.
Базовый пример
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\QueryBuilder;
/** @var Connection $connection */
$queryBuilder = $connection->createQueryBuilder();
$queryBuilder
->select('p.*', 'c.name as category_name')
->from('products', 'p')
->join('p', 'categories', 'c', 'p.category_id = c.id')
->where('p.price > :minPrice')
->andWhere('p.stock > 0')
->setParameter('minPrice', 50);
$sql = $queryBuilder->getSQL();
$params = $queryBuilder->getParameters();
$types = $queryBuilder->getParameterTypes();
echo "SQL: " . $sql . PHP_EOL;
// SQL: SELECT p.*, c.name as category_name FROM products p INNER JOIN categories c ON p.category_id = c.id WHERE p.price > :minPrice AND p.stock > 0
echo "Params: " . json_encode($params) . PHP_EOL;
// Params: {"minPrice":50}
// Полностью подставленный запрос (имитация)
$fullSql = $sql;
foreach ($params as $key => $value) {
$fullSql = str_replace(':' . $key, is_string($value) ? "'$value'" : $value, $fullSql);
}
echo "Full SQL: " . $fullSql;
// Full SQL: SELECT p.*, c.name as category_name FROM products p INNER JOIN categories c ON p.category_id = c.id WHERE p.price > 50 AND p.stock > 0
Нюанс: Doctrine не умеет сам подставлять параметры в строку — это сделано намеренно, чтобы избежать SQL-инъекций. Поэтому ручная подстановка — только для отладки, не для продакшена!
Метод 3: Универсальный — слушаем события (для любой ORM)
Если вам нужно логировать все запросы, а не один конкретный, лучший способ — подписаться на события выполнения запросов. Это сработает и для Laravel, и для Doctrine, и для Propel.
Laravel: глобальный слушатель
// В AppServiceProvider::boot()
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
DB::listen(function ($query) {
$sql = $query->sql;
$bindings = $query->bindings;
$time = $query->time;
// Формируем полный запрос
$fullSql = preg_replace_array('/\?/', $bindings, $sql);
Log::channel('sql')->info('Query executed', [
'sql' => $fullSql,
'time' => $time . ' ms',
]);
});
Плюс: Вы видите все запросы, включая те, что делает Eloquent при загрузке отношений. Минус: На продакшене лучше не включать — убьёт производительность.
Doctrine: профайлер в Symfony
// В config/packages/doctrine.yaml
doctrine:
dbal:
logging: true
profiling: true
// В контроллере или сервисе
/** @var \Doctrine\DBAL\Logging\DebugStack $logger */
$logger = $this->get('doctrine.dbal.logger.chain.default');
$queries = $logger->queries;
foreach ($queries as $query) {
echo $query